Skip to content

Scale lines, blur and texsize with the render resolution (implements #682) - #1047

Open
johnneerdael wants to merge 7 commits into
projectM-visualizer:masterfrom
johnneerdael:feat/quad-lines-682
Open

johnneerdael wants to merge 7 commits into
projectM-visualizer:masterfrom
johnneerdael:feat/quad-lines-682

Conversation

@johnneerdael

@johnneerdael johnneerdael commented Oct 12, 2026 •

Copy link
Copy Markdown

Summary

Implements #682, Improve line rendering on higher resolutions. Closes #682.

projectM draws waveforms, custom waves, shape outlines and motion vectors as 1 px GL lines, like Milkdrop. At 3840×2160 a 1 px line covers about a third of the share of the picture it had at Milkdrop's 1024×768, where it would need to be 3.25 px wide. Line-heavy presets get thin and dark on 4K screens, and presets that feed their lines back into the image can go almost black. The blur levels and the texsize steps in preset shaders shrink the same way.

This PR adds an opt-in line reference size. When it's set, lines are drawn as quads (one instance per segment, miter joins) that are 1 px wide at the reference area and wider above it, so a line keeps its share of the picture. Thick lines, waveform dots, the blur levels and the canvas size that preset code sees scale the same way, and so do two Milkdrop rules that depend on the render width. The default is 0, which renders exactly as before.

I maintain ProjectM TV, which has shipped this on Android TV since its 2.2 release. As I mentioned in the issue, scaling lines down below 1080p made presets darker, so lines never get thinner than 1 px. This is a clean port to current master without the TV app's other renderer changes.

API

void projectm_opengl_set_line_reference_size(projectm_handle instance, uint32_t width, uint32_t height);
void projectm_opengl_get_line_reference_size(projectm_handle instance, uint32_t* width, uint32_t* height);
void projectm_opengl_set_line_antialiasing(projectm_handle instance, bool enabled);
bool projectm_opengl_get_line_antialiasing(projectm_handle instance);

I'd suggest 1024×768, Milkdrop's authoring resolution. 0 in either dimension is the default. The SDL test UI reads Line Reference Width, Line Reference Height and Line Antialiasing from config.inp; all three are off by default.

How lines are sized

With render size W×H and reference Wr×Hr, the line scale is s = max(1, sqrt(W·H / (W_r·H_r))) and a line is s pixels wide. Using the area keeps the share of the picture the same at any aspect ratio; at the reference's aspect ratio it's just H / Hr. With 1024×768 that gives 1.62 px at 1920×1080 and 3.25 px at 3840×2160. The lower bound of 1 is there because thinner, faded lines made feedback presets go dark when I tested them.

The width is measured along the line's minor axis, as GL lines do. A GL line covers one pixel per column (or row) at any angle, so a 45° line is only 1/√2 px wide perpendicular to itself. The quad uses a perpendicular offset of s/2 · max(|dx|, |dy|) for a unit direction (dx, dy), which gives a diagonal quad line the same pixel coverage as the GL line it replaces.

Each instance also reads the points before and after its segment. Corners are offset along the bisector of the two normals and lengthened by 1/cos(θ/2) so both edges stay at the right distance. For turns sharper than about 120° (cos < 0.49) and at strip ends the segment keeps its own normal, which avoids long spikes. I didn't use the round joins suggested in the issue because they draw every joint twice, which brightens additive and feedback presets.

Thick lines use Milkdrop's own scheme: the thin line drawn four times, offset by (0, 0), (x, 0), (x, y) and (0, y), with the offsets scaled by s. That's one reference pixel for the main wave and half a pixel for custom waves and shape outlines, as projectM does today. A single wider line hits pixels a different number of times, and feedback presets amplified that by 10 to 30% in testing.

Dots stay GL points. A dot is b·s pixels rounded up to a whole pixel, where b is 2 for main-wave dots and thick custom-wave dots and 1 otherwise, and its alpha is multiplied by (b·s)² / ceil(b·s)² so its brightness matches its intended area. Main-wave dots are drawn once at that size instead of four times at one-pixel offsets.

Edges are hard by default, like GL lines, because presets that feed their image back are tuned for that; a soft edge made one such preset 14% darker. projectm_opengl_set_line_antialiasing() turns on a one-pixel ramp.

At the reference size a quad line lights the same pixels as Milkdrop's 1 px line, apart from a few at sharp turns. On GLES the quads are moved by 1/64 px so that a line lying exactly on a pixel border lights the same row as a Mali-G52's GL lines (checked on a device in ProjectM TV). Desktop GL needs no offset.

Other Milkdrop quantities that follow the reference

These use the reference size instead of the render size above the reference area. At or below it, nothing changes.

  • Waveform point count. Line waveforms draw a third of their samples when there are more samples than a third of the render width (Milkdrop's samples > width / 3). At 4K that means 480 points instead of the 160 an author working at 1024 px wide saw. The rule now uses W / s, the reference-equivalent width. Without this, $$$ Royal - Mashup (103) was 2.3× as bright as authored at 1080p (ablation from the original work).
  • The spiro and hash wave fade. Waveform::MaximizeColors() fades wave modes 2, 3 and 5 by the texture size, and now uses the reference size.
  • Blur levels. Milkdrop builds blur1 to blur3 as fixed fractions of its canvas and blurs them by a few of their own texels, so a blur's radius is a fixed share of the picture only at one render size. The blur chain is now built as if from a source of the reference's area (W/s × H/s). The first pass reads the frame through its mipmaps at log2(s) (textureLod with a trilinear sampler, after glGenerateMipmap), so every source pixel still contributes.
  • The canvas size presets see. Preset shaders step by texsize.zw texels and turn UVs into pixels with texsize.xy, tuned on a canvas of about the reference size. texsize, texsize_main, mip_x/y/avg and the per-frame and per-pixel pixelsx/pixelsy now report W/s × H/s. Textures and their sampling stay at the render size, and texsize_<name> for real textures is unchanged.

Implementation

LineGeometry holds the maths as plain functions (LineScale, LineStyleFor, DotStyleFor, SampleDecisionWidth, MaximizeColorsTextureSize, BlurSourceFor, ShaderCanvasSize), plus LineBatch, which pads strips so that instance i reads points i to i+3.

LineRenderer lives in PresetState, one per preset. It uploads a batch and draws it with glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, segments). Its program is compiled on first use and shared through the instance's ShaderCache, and its buffers are created on first use too. Waveform, CustomWaveform and CustomShape (outlines) use it when a reference size is set. MotionVectors draws all vectors in one instanced call with its own vertex shader, which reads the warp's u/v texture the same way the existing one does.

BlurTexture::Update() takes the line scale, and MilkdropShader, PerFrameContext and PerPixelContext report ShaderCanvasSize(). If a line program fails to compile or link, that preset draws GL lines. The new and changed shaders link as GLSL 3.30 and GLSL ES 3.00.

Evidence

The images, data, harness and preset files are on a separate evidence branch of my fork, with a README that covers the method and limits. None of it is part of this PR.

Each preset is rendered on the same frames three ways: upstream master at 3840×2160, this PR at 3840×2160 with reference 1024×768, and upstream master at 1182×665. That last one is 16:9 with the area of 1024×768, roughly what the preset authors saw, so I call it the authored look. The harness only uses the public C API. It ran on an Apple M4 Pro (OpenGL 4.1) with fixed synthetic audio, projectm_set_frame_time(frame / 30) and a 48×32 mesh. Images are frame 239, and brightness is mean luma over frames 120 to 239.

Unmodified upstream doesn't render the same frame twice. Random and noise textures, rand_preset and the first TimeKeeper update are seeded from random_device or the wall clock, and new framebuffer textures start out undefined. So I built both libraries with the same evidence-only patch that fixes those seeds. It isn't part of this PR.

I rendered 124 Cream of the Crop presets (every 8th by name, stopped after 124). 103 of them have a usable brightness at the authored size. Mean luma relative to the authored look:

median within ±10% below 0.5× above 1.5×
upstream, 3840×2160 0.89× 39 / 103 21 0
first commit only (lines), 3840×2160 1.00× 67 / 103 2 2
this PR, 3840×2160 1.02× 75 / 103 0 6
upstream, 1920×1080 0.95× 56 / 103 4 0
this PR, 1920×1080 1.01× 83 / 103 0 2

At 4K this PR is closer to the authored look for 77 presets and further away for 22. The six that end up above 1.5× are feedback presets that react strongly to small differences, for example 271 nz+ m21 (0.39× to 3.15×) and $$$ Royal - Mashup (158) (0.76× to 1.62×); the evidence README lists all six. What's left is the bilinear smoothing of the feedback image per real texel, which this PR leaves alone.

With the reference size at 0, 109 of 123 presets give byte-identical frames to upstream. The other 14 also vary between repeated runs of unmodified upstream, and in reruns each of them gave at least one frame that upstream also gave.

Real presets mix lines with feedback, so I also measured the strokes on their own in ProjectM TV, which uses the same line code. These were synthetic presets without feedback, with the canvas read through a point-sampling composite and energy taken as linear sums. Compared with 1 px GL lines at 1182×665, thin and thick custom waves at 1080p and 4K were within 0.47% in energy and 0.44% in lit area, with nearly the same distribution of per-pixel hit counts (largest histogram bin difference 0.0036). Alpha-blended thick custom waves were within 0.35%. (data, method)

Presets with the clearest difference

I re-rendered the 30 presets with the largest 4K difference at full size and kept the 16 where the change toward the authored look is easy to see. Each image shows upstream at 4K, this PR at 4K and the authored look, downscaled. The labels sit above the pixels and nothing is brightened.

13 (mean luma: upstream 0.029, this PR 0.150, authored 0.149)

13.milk: upstream 4K, this PR 4K, authored look

A Feature Preset Master Exclusive ORB - Planetary Alignment Acidburn - Hexocollie, Flexi AdamFX,Martin b (mean luma: upstream 0.251, this PR 0.261, authored 0.279)

A Feature Preset Master Exclusive ORB - Planetary Alignment Acidburn - Hexocollie, Flexi AdamFX,Martin  b.milk: upstream 4K, this PR 4K, authored look

$$$ Royal - Mashup (295) (mean luma: upstream 0.005, this PR 0.253, authored 0.221)

$$$ Royal - Mashup (295).milk: upstream 4K, this PR 4K, authored look

Amandio C - Three (Flexi shader code) (mean luma: upstream 0.418, this PR 0.432, authored 0.423)

Amandio C - Three (Flexi shader code).milk: upstream 4K, this PR 4K, authored look

12 more presets

AdamFX 2 Geiss - Mash-Up Sphere Xibit Graffiti Warp me Oval Interprise3 (mean luma: upstream 0.370, this PR 0.334, authored 0.332)

AdamFX 2 Geiss - Mash-Up Sphere Xibit Graffiti Warp me Oval Interprise3.milk: upstream 4K, this PR 4K, authored look

$$$ Royal - Mashup (541) (mean luma: upstream 0.114, this PR 0.263, authored 0.234)

$$$ Royal - Mashup (541).milk: upstream 4K, this PR 4K, authored look

Adam Fx 2 Zylot - Age of Science Fierceness viynal - mash0000 - wheel of misanthropic misfortune (mean luma: upstream 0.429, this PR 0.424, authored 0.416)

Adam Fx 2 Zylot - Age of Science Fierceness viynal - mash0000 - wheel of misanthropic misfortune.milk: upstream 4K, this PR 4K, authored look

14 (mean luma: upstream 0.008, this PR 0.162, authored 0.158)

14.milk: upstream 4K, this PR 4K, authored look

$$$ Royal - Mashup (286) (mean luma: upstream 0.566, this PR 0.522, authored 0.446)

$$$ Royal - Mashup (286).milk: upstream 4K, this PR 4K, authored look

$$$ Royal - Mashup (118) (mean luma: upstream 0.181, this PR 0.354, authored 0.291)

$$$ Royal - Mashup (118).milk: upstream 4K, this PR 4K, authored look

$$$ Royal - Mashup (137) (mean luma: upstream 0.480, this PR 0.610, authored 0.609)

$$$ Royal - Mashup (137).milk: upstream 4K, this PR 4K, authored look

$$$ Royal - Mashup (184) (mean luma: upstream 0.164, this PR 0.153, authored 0.135)

$$$ Royal - Mashup (184).milk: upstream 4K, this PR 4K, authored look

$$$ Royal - Mashup (1) (mean luma: upstream 0.275, this PR 0.321, authored 0.321)

$$$ Royal - Mashup (1).milk: upstream 4K, this PR 4K, authored look

$$$ Royal - Mashup (355) (mean luma: upstream 0.092, this PR 0.226, authored 0.216)

$$$ Royal - Mashup (355).milk: upstream 4K, this PR 4K, authored look

Bdrv EoS_Phat blah_blah bdrv etAL rolo nz (mean luma: upstream 0.355, this PR 0.321, authored 0.323)

Bdrv EoS_Phat blah_blah bdrv etAL rolo nz.milk: upstream 4K, this PR 4K, authored look

$$$ Royal - Mashup (422) (mean luma: upstream 0.199, this PR 0.281, authored 0.278)

$$$ Royal - Mashup (422).milk: upstream 4K, this PR 4K, authored look

Lines only vs. lines plus blur and texsize

Each image shows upstream at 4K, the first commit (lines only) at 4K, this PR at 4K and the authored look. With only the line width scaled, the authored structure still comes out too fine at 4K. Once the blur levels and texsize follow the reference, strokes, blobs and cells get their authored size.

A Feature Preset Master Exclusive ORB - Planetary Alignment Acidburn - Hexocollie, Flexi AdamFX,Martin b

A Feature Preset Master Exclusive ORB - Planetary Alignment Acidburn - Hexocollie, Flexi AdamFX,Martin  b: upstream, lines only, this PR, authored look

AdamFX 2 Geiss - Mash-Up Sphere Xibit Graffiti Warp me Oval Interprise3

AdamFX 2 Geiss - Mash-Up Sphere Xibit Graffiti Warp me Oval Interprise3: upstream, lines only, this PR, authored look

Amandio C - Three (Flexi shader code)

Amandio C - Three (Flexi shader code): upstream, lines only, this PR, authored look

$$$ Royal - Mashup (286)

$$$ Royal - Mashup (286): upstream, lines only, this PR, authored look

100% crops

Unscaled 640×360 regions of the two 4K frames, taken where upstream and this PR differ most.

AdamFX 2 Geiss - Mash-Up Sphere Xibit Graffiti Warp me Oval Interprise3

AdamFX 2 Geiss - Mash-Up Sphere Xibit Graffiti Warp me Oval Interprise3.milk: 100% crop, upstream and this PR

A Feature Preset Master Exclusive ORB - Planetary Alignment Acidburn - Hexocollie, Flexi AdamFX,Martin b

A Feature Preset Master Exclusive ORB - Planetary Alignment Acidburn - Hexocollie, Flexi AdamFX,Martin  b.milk: 100% crop, upstream and this PR

Amandio C - Three (Flexi shader code)

Amandio C - Three (Flexi shader code).milk: 100% crop, upstream and this PR

5 more crops

Adam Fx 2 Zylot - Age of Science Fierceness viynal - mash0000 - wheel of misanthropic misfortune

Adam Fx 2 Zylot - Age of Science Fierceness viynal - mash0000 - wheel of misanthropic misfortune.milk: 100% crop, upstream and this PR

$$$ Royal - Mashup (286)

$$$ Royal - Mashup (286).milk: 100% crop, upstream and this PR

$$$ Royal - Mashup (137)

$$$ Royal - Mashup (137).milk: 100% crop, upstream and this PR

$$$ Royal - Mashup (355)

$$$ Royal - Mashup (355).milk: 100% crop, upstream and this PR

Bdrv EoS_Phat blah_blah bdrv etAL rolo nz

Bdrv EoS_Phat blah_blah bdrv etAL rolo nz.milk: 100% crop, upstream and this PR

Cost

Median frame time at 4K with glFinish around each frame, two runs each:

Preset What it draws Upstream This PR
Flexi - alien complex 03 many outlined shapes 16.0 / 16.4 ms 16.3 / 16.3 ms
Fvese-mvfun2 64×48 motion vectors 3.85 / 3.74 ms 3.98 / 3.91 ms
Bdrv yin - 315 - Ocean of Light custom waves 5.07 / 5.03 ms 5.03 / 5.03 ms
$$$ Royal - Mashup (103) main wave 3.94 / 3.68 ms 1.99 / 1.92 ms
fat cancer tour meant t nz+ blur 3.35 / 3.38 ms 3.21 / 3.27 ms
TonyMilkdrop - Nuclear blur 3.05 / 3.06 ms 2.92 / 2.97 ms

Mashup (103) gets faster because the point-count rule draws 160 points at 4K instead of 480. The blur presets get a little faster because the blur levels are smaller at 4K, even with the per-frame mipmap generation. These are desktop numbers. On an Ugoos AM6 (Mali-G52, Android 9), ProjectM TV measured the line drawing at 0.9 to 3.9% slower on line-heavy presets at 1080p and 4K, and a blur-heavy preset 24% faster at 4K.

Renderer refactor

Reusable colored points, line batching, line drawing and the generic line shaders live in Renderer. MilkDrop-specific sizing, waveform sample/fade rules, dot styles, shader-canvas policy and thick-line pass offsets remain in MilkdropPreset.

Renderer::Mesh now owns and streams interleaved instance data, configures custom attribute layouts/divisors with per-strip offsets, and draws a separate vertex/instance count. Both line strips and motion-vector quads use it. The existing indexed draw path is retained. The motion-vector vertex shader remains with the preset code because it samples the preset warp coordinates; it shares Renderer’s line fragment shader.

Testing

  • projectM-unittest: all 207 tests pass, including 39 new LineGeometry / LineBatch tests covering scale, sample width, fade size, blur source, shader canvas size, thick-line passes and offsets, dot sizes and strip padding. Six Mesh tests cover the unchanged indexed path, interleaved instance layouts and strip offsets, divisors, streaming uploads, independent vertex/instance counts, empty draws and lazy buffer ownership.
  • Release build on macOS (AppleClang), including the SDL test UI, with no new warnings from the refactor.
  • glslangValidator -l: the line programs and the changed blur program link as #version 330 and #version 300 es.
  • Focused refactor regression on Apple M4 Pro / OpenGL 4.1: 45 paired synthetic cases across 640×360, 1920×1080 and 3840×2160, with reference sizing off/on, anti-aliasing and injected line-program compile/link failures. All 540 RGB frame pairs match the pre-refactor library byte-for-byte (12 frames per case, fixed time and synthetic stereo PCM). The scenes exercise double-strip main waves, custom waves, multi-instance shape outlines, dots and motion vectors. No GL errors or preset-load failures were observed.
  • The moved Renderer sources and Mesh tests also pass a USE_GLES C++ syntax check; the GLSL 300 ES programs link with glslangValidator. This is not a GLES device-runtime claim.

I haven't built this branch on Linux, Windows, Emscripten or Android (that's up to CI here), and I haven't run this port on a GLES device. The same code runs on Mali-G52 and Mali-G310 Android TVs in ProjectM TV. I haven't checked NVIDIA Tegra, where pixel-border ties may land on a different row.

Not included

  • Round joins and end caps from the issue, for the reason given above.
  • The feedback smoothing. Bilinear re-sampling of the previous frame smooths per real texel, so above about twice the reference some feedback presets still settle into a different pattern. ProjectM TV deals with that in the app (a render-size cap and its own feedback path), which I don't think belongs in the library.
  • A default reference size. It's opt-in so integrations can choose; I'd suggest 1024×768.

Risk and compatibility

  • Default output is unchanged. All the new paths need a reference size. With 0, the existing paths only move code around: MaximizeColors() now returns the color and the caller sets it, and the first blur pass reads with textureLod(…, 0) on the same non-mipmapped linear sampler. The byte-identity check above covers this.
  • API: four new functions and no changed signatures. BlurTexture::Update() gets a parameter with a default value.
  • Resources: with the feature off, the only addition is one GL sampler object per preset for the scaled blur. With it on, line and motion-vector meshes are created on first use, each with a VAO, the standard Mesh buffers and a streamed instance buffer; each projectM instance compiles two programs (all on first use), and the frame texture the blur reads gets a mipmap chain, about a third more memory for that texture.
  • Old drivers: if a line program doesn't compile, that preset keeps GL lines.

🤖 Generated with Claude Code

jneerdael-netskope and others added 6 commits October 12, 2026 06:06
…ectM-visualizer#682)

projectM, like Milkdrop, draws waveforms, custom waves, shape outlines and
motion vectors as 1 px GL lines. The higher the render resolution, the
smaller the share of the picture they cover, so many presets get thin and
dark at 1080p and 4K.

projectm_opengl_set_line_reference_size() draws them as quads instead, one
instance per segment with miter joins. Lines are 1 px wide at and below the
reference area and widen by sqrt(render area / reference area) above it.
Thick lines keep Milkdrop's four offset passes with scaled offsets, dots get
whole-pixel sizes with area-compensated alpha, and edges stay hard unless
projectm_opengl_set_line_antialiasing() is enabled. Above the reference
area, Milkdrop's waveform sample-count rule and the spiro/hash wave fade
follow the reference size too, so dense waves don't wash out.

The default reference size is 0, which keeps the existing GL lines. If the
driver rejects the line shaders, GL lines are drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
So presets allocate nothing for scaled lines while no line reference size
is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Milkdrop builds its blur levels as fixed fractions of its canvas, blurred by
a few of their own texels, and preset shaders step by texsize texels. Both
are a share of the picture only at the canvas size the preset was made on,
so at 4K blurs are narrower and texel-step effects move slower and smaller
than authored, whatever the line width.

Above the line reference area:
- the blur levels are made as from a source of the reference's area (the
  render size divided by the line scale); the first pass reads the source
  through its mipmaps at log2 of the scale, so every source pixel counts;
- texsize (_c7), texsize_main, mip_x/y/avg and the per-frame and per-pixel
  pixelsx/pixelsy report that canvas size. Textures and their sampling stay
  at the render size.

Without a reference size, or at or below its area, nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@johnneerdael johnneerdael changed the title Scale line width with the render resolution (#682) Scale lines, blur and texsize with the render resolution (implements #682) Oct 12, 2026
@kblaschke

Copy link
Copy Markdown
Member

Since the line drawing is more a rendering feature than a preset thing, the LineGeometry and LineRenderer classes would also fit better into the Renderer namespace, as this isn't really preset-specific code.

You could also reuse the Renderer::Mesh class to store, update and draw the line geometry, as it's a convenient container/wrapper for vertex buffers and attribute pointers. Since you're using instanced drawing, this probably needs to be added as a feature to the mesh class.

@johnneerdael

Copy link
Copy Markdown
Author

@kblaschke Moving the reusable line renderer and batching code into Renderer makes sense. LineGeometry currently also contains MilkDrop-specific sizing and waveform rules, so I’d keep those in MilkdropPreset and separate out the generic geometry.

I’ll look at extending Mesh for the instanced path. Besides instanced drawing, it needs custom attribute layouts, divisors and per-strip offsets for the overlapping point data. I’d also move the generic line shader resources so Renderer stays independent of MilkdropPreset.

Keep MilkDrop sizing and thick-line pass policies in MilkdropPreset. Extend Mesh with streamed instance data, custom attribute layouts and instanced draws, and use it for line strips and motion-vector quads.

Validate with 207 unit tests, desktop/GLES shader linking, GLES syntax checks and 45 paired real-GL regression cases with exact RGB frame matches, including shader failure fallback.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Improve line rendering on higher resolutions

3 participants