More 3D, More Filters
The last four posts were all big structural changes — HDR, the framegraph, post-FX, PBR, and IBL. Each one was its own post because each one was its own idea. But alongside them, a dozen smaller nodes landed that don’t individually justify a post and don’t deserve to stay unwritten-about.
This is the cleanup post. One short section per node family. No single thesis.
Cylinder, Cone, Torus
Three primitives that should have been in the boxes and spheres post and weren’t, because I ran out of time.
Cylinder — Y-aligned, smooth-shaded barrel with flat top and bottom caps. Inputs: radius, height, segments. The barrel vertices get normals that point radially outward; the cap vertices get normals that point along the Y axis. That split is important — if the barrel and the cap shared vertex normals, the seam between them would look like one continuous surface, and the cap would disappear into the curve.
Cone — same story as Cylinder but with a pointed top. The interesting bit is the barrel normals aren’t purely radial; they tilt upward by the cone’s half-angle so the shading follows the slope. Without this, the sides of the cone look disc-flat, like a cylinder someone squished.
Torus — a donut in the XZ plane. Two subdivision parameters: radial_segments (around the big loop) and tubular_segments (around the small loop). Smooth-shaded throughout. Normals are computed analytically per vertex — torus UV parameterisation has a clean closed form, and UV-derived normals match it exactly.
All three follow the same pattern as every other primitive: stateful, content-addressed mesh upload, cache invalidates on input change, re-upload only when geometry actually changes. The torus_phong regression test renders a Phong-shaded torus and pixel-checks a central row — visible shading gradient, no broken topology, matches the reference PNG byte-for-byte.
Twist3D
A mesh deformer. Classic TouchDesigner “Twist SOP” semantics: every vertex rotates around a chosen axis by an angle proportional to its signed distance along that axis. A box becomes a twisted column. A cylinder becomes a corkscrew. A plane becomes a spiral.
Inputs: mesh, axis (X/Y/Z enum), angle (degrees of twist per unit distance), center (optional pivot offset).
The fragment that makes this node interesting isn’t the math — the math is straightforward per-vertex rotation — it’s that Twist3D reads an upstream mesh’s vertex arrays CPU-side. No other 3D node does this. Every other node either produces meshes (primitives) or consumes them (RenderScene). Twist has to see the actual positions, normals, and tangents of the input mesh so it can transform them.
That meant changes to ProcessContext. I added a frame-level mesh_data snapshot map: every TextureOp::UploadMesh folds its MeshData into the map keyed by handle, and every FreeMesh removes it. During evaluation, a deformer node can call ctx.mesh_data(handle) and get an Arc<MeshData> back — a read-only view of the upstream mesh’s arrays, no GPU round-trip, refcount-bump cheap.
The deformer builds a new MeshData on its output, uploads it through its own mesh builder, and the downstream scene gets the deformed mesh. Cached, so a Twist with unchanged inputs does zero work per frame.
Normals rotate by the same Mat3 as positions. UVs pass through unchanged (twist is a rigid motion on the surface; UVs are intrinsic to each vertex and don’t care where the vertex is in 3D space). Tangents rotate by the Mat3 too, so normal maps continue to work on a twisted surface.
This opens the door to a whole family of future deformer nodes — Bend, Taper, Noise, SoftBodyDeform — all of which share the same “read upstream MeshData, transform vertices, emit new mesh” pattern. Twist is the proof of concept.
Gizmo3D
A one-node SRT manipulator. Scale, Rotate, Translate, all in one widget, rendered on top of the scene as three coloured axis rings and arrows.
Drag the red arrow to translate along X. Drag the green ring to rotate around Y. Drag the scale handles to scale uniformly or per-axis. Shift-drag to snap. Ctrl-drag to constrain to a single axis.
The node outputs a Transform (Matrix4) that represents the current manipulator state. Wire it into a SceneObject’s transform pin and the object follows the gizmo. Wire multiple SceneObjects into the same gizmo and you move them together.
The gizmo’s rendering lives in a dedicated overlay pass in RenderScene — it runs after the main scene render, against the scene’s depth buffer but with DepthCompare::Always so it always draws on top of the scene geometry. The interaction picking uses viewport ray-casts against the gizmo’s handle geometry; hit-testing is all in world space after unprojecting the cursor.
Saved as part of the project file via the node’s state. Open a project, the gizmo comes back in the same configuration, with the same transform, affecting the same scene objects.
This is the first node in Lux that has a persistent, interactive, on-canvas spatial UI. Every previous node has been wires-and-pins with maybe a colour picker. Gizmo3D is the one that started to look like a real 3D editor.
SSAO
Screen-space ambient occlusion, as a post-FX node. Darkens corners and crevices using the scene’s depth buffer. Sample pseudo-random offsets around each pixel, check how many of them are hidden by nearby geometry, average the result, multiply the output colour by the occlusion factor.
Inputs: color, depth, near, far, radius, intensity, samples. The depth pin accepts the Depth output from RenderScene via ShaderInput::DepthTexture — the same mechanism DOF uses to read depth textures plugin-side.
SSAO is not physically correct in any way. It’s a shader trick that approximates the effect of occluders shadowing ambient light. It’s also one of the most consistently used post-FX in every game engine, because scenes without it look flatter than scenes with it. The effect is subtle individually and significant in aggregate. I’m told that’s the correct take. It also matches what I see, which might be coincidence.
The test patch renders four instanced boxes, applies SSAO, and pixel-checks that the contact points between the boxes and the ground plane are darker than the open floor between them. Which is exactly what SSAO is supposed to do.
RampTexture
A procedural gradient texture. Four modes: horizontal (gradient along X), vertical (along Y), radial (distance from centre), circular (angular sweep around centre).
Inputs: color_a, color_b, offset, scale, mode. The shader is one of the simpler ones — compute a gradient parameter from UVs, lerp between colors, write. Cached; re-renders only on input change.
Useful as a test pattern, a mask source, a UV remap input, or the base for something more complex. The reason to ship it as a built-in is that every creative coder writes “procedural gradient” as one of their first shaders, and having it as a drag-and-drop node is faster than opening PixelShader and pasting WGSL.
LumaBlur
A texture filter that blurs each pixel by a radius proportional to a mask texture’s luminance. Bright mask area = heavy blur. Dark mask area = no blur. Gradual transitions in between.
Inputs: texture, mask, max_radius (0-64), samples (4-32). The shader reads the mask’s BT.601 luminance, multiplies by max_radius to get a per-pixel radius, and runs an N-sample disc blur at that radius.
This is the node that makes depth-of-field-like effects possible without a depth buffer. Wire a gradient mask into the mask input and you get a blur that intensifies across the image. Wire a noise texture in and you get a patchy, broken-focus look. Wire a procedural circle mask in and you get a vignette-shaped blur.
It’s one of those nodes that covers a surprising amount of creative territory given how little it does mechanically.
Level
A gamma-and-remap filter. Takes an input texture and three parameters: gamma (non-linear transfer curve), black_point (input value mapped to pure black), white_point (input value mapped to pure white).
The shader remaps (input - black) / (white - black), then raises the result to 1 / gamma. Standard Photoshop Levels behaviour, implemented as one math expression per pixel.
Useful for normalising raw captured data, matching video footage to a different target brightness range, or just as a colour grading operator that’s simpler than ColorMatrix but more controllable than Brightness.
FbmNoise
Fractal Brownian Motion — multiple octaves of Perlin noise summed with decreasing amplitude and increasing frequency. The classic procedural detail primitive.
Inputs: width, height, octaves, lacunarity, persistence, seed. The shader loops octaves times, sampling Perlin noise at increasingly fine scales, weighting each octave by persistence^i. Output is a grayscale texture.
This is a superset of the existing NoiseTexture node in the sense that one octave of FbmNoise is a plain Perlin pattern, but the multi-octave result looks much more organic — think clouds, cracked earth, distant mountains, water surfaces. It’s the go-to noise source for anything that wants to look natural rather than algorithmic.
HeightToNormal
Converts a grayscale height map into a tangent-space normal map. Fragment shader samples the height at the current pixel and at four neighbours, computes horizontal and vertical height gradients with a Sobel-like kernel, encodes the result as (dx, dy, 1) normalised, and writes to RGB.
Inputs: height_texture, strength. The strength parameter scales the gradients before normalisation — high strength produces steeper-looking normals; low strength produces a subtle perturbation.
Wire an FbmNoise into HeightToNormal into a PBR material’s normal_map pin, and you’ve got a procedurally textured surface with no external assets. Wire a hand-painted grayscale image into the same chain and you’ve got a custom material. It’s the bridge between procedural/painted height fields and the PBR pipeline.
What it adds up to
Nine new nodes. Three new primitives, one deformer, one gizmo, four filter-or-source. Each one’s its own line in the changelog; each one’s another knob an artist can turn without writing a shader.
The big structural posts get the headlines, but nodes like these are what make the big structural posts useful. An HDR-aware PBR pipeline is only worth having if you can author surfaces for it. A framegraph is only worth having if real nodes plug into it. Cylinder and Cone and Torus and Twist and the four filters are the fill-in-the-blanks work that makes the rest of the render rewrite land.
One more render-side post before the session moves to editor polish. Async readback and the StagingBelt — two perf wins that didn’t visibly change anything but unblocked ~3ms of frame time I’d been leaking.
I have no idea what I’m doing or if any of this is right, but it’s fun. Follow along.