VKO1 · 3D Environment

Real 3D,
Inside the Graph.

Parametric meshes, imported models, and a shared camera you can orbit with your finger — all living inside the same node graph as every other generator. Depth-buffered. Audio-reactive. Shipping in VKO1 1.22 in a few weeks.

Bass
Torus Knot 3D
Bloom
Output

A Live 3D Stage.

Until now VKO1 rendered everything through a flat fullscreen quad. The 3D Environment adds vertex-buffer geometry, a real depth buffer, a proper camera, and three showcase 3D generators — all flowing through the same modifiers, combiners, and pads you already know.

Parametric meshes

Torus Knot and Icosphere Terrain are built live on device. Audio-reactive vertex displacement, Blinn-Phong + fresnel shading, and the same 33 palettes as the rest of VKO1.

Import your models

Drop in a .usdz, .obj, .ply, or .abc from your Files app. Auto-centered, auto-scaled, auto-normalized. Swap files anytime from the inspector.

Orbit with your finger

The preview panel is now a live 3D viewport. Drag to orbit, pinch to zoom, double-tap to reset. The camera is shared across every 3D node in the graph.

It just plugs in. 3D generators output a standard texture pin, so every modifier, combiner, and preset you've built still works. You can chain Bloom after a Torus Knot, feed it into a Beat Crossfade alongside a 2D Plasma, and stack the new 2.5D Perspective 3D modifier on top of a real 3D scene.

Three New 3D Generators.

Every 3D generator lives in the Generators category of the node palette. They share the same pin set as the 2.5D Perspective 3D modifier, so if you've wired that, you already know how these work.

Parametric

Torus Knot 3D

A (p=2, q=3) torus knot built from a Frenet-framed tube surface. Iridescent Blinn-Phong + fresnel shading. Vertex displacement pulses along the normal with beat strength. Detail controls segment count.

Parametric

Icosphere Terrain 3D

A 12-vertex icosahedron subdivided 0–3 times based on Detail. Radius displaces with bass energy + beat strength — valleys become peaks on every kick. Palette sampled by displaced radius.

Imported

3D Model NEW

Loads .usdz, .usd, .obj, .ply, or .abc files from disk via ModelIO. Auto-centered and normalized to ~1.8 units. Missing normals and UVs are synthesized automatically.

Shared pins

Reactivity · Complexity · Speed

Same as every other generator. Reactivity scales the audio response, Complexity controls mesh detail, Speed multiplies auto-rotation.

rotateX · rotateY · rotateZ

Three float pins, each 0–1 mapped to 0–2π. Wire Bass → rotateX, LFO → rotateY, Beat Phase → rotateZ for an instant audio-reactive tumble.

Camera Distance · Color Palette

Per-node base dolly-in/out. The user-interactive viewport camera zoom stacks on top. Color palette picks from the same 33 palettes as every other generator.

Typical 3D signal chains
GenTorus Knot 3D
ModBloom
OutOutput
InBass
──(rotateX)──▶
GenIcosphere Terrain
ModColor Controls
OutOutput
Gen3D Model
ModPerspective 3D
ModGlitch
OutOutput
Dark variant. Each 3D node renders into its own node texture with a shared depth buffer that's cleared per pass. You can have multiple 3D generators in the same graph — each one lives in its own little world, and combiners stitch them together like any 2D texture pair.

Bring Your Own Mesh.

The 3D Model generator is a fourth type of import after audio, video, and images. Pick a file once — it's cached in memory, loaded off the main thread, and ready to render within a beat.

1

Add the node

Right-click (or long-press) an empty pad and choose Create Visualizer from Scratch. Inside the node editor, open the node palette and tap 3D Model under Generators. It drops onto the canvas with a cube icon and the usual rotation pins.

2

Pick a file

Select the node and tap Pick Model in the inspector. Browse to any .usdz, .usd, .obj, .ply, or .abc file in Files, iCloud, or on the Mac filesystem.

3

Auto-fit

The mesh is recentered and scaled to ~1.8 units across so it always fills the frame. Missing vertex normals are generated with a 0.2 crease threshold; missing UVs fall back to planar-XY.

4

Wire it up

Connect the output to any modifier chain. Wire audio inputs into rotateX/Y/Z. Add Bloom, Color Controls, Perspective 3D — everything downstream still works.

Triangulated meshes only. USDZ from Reality Composer, glTF exports, and modern OBJ/PLY exporters all ship triangulated by default. If a submesh isn't triangles, the loader skips it rather than guessing. Animation playback isn't supported yet — static meshes only.

The Preview Is Now a 3D Viewport.

The floating preview panel has been rebuilt from the ground up. It's resizable, it has a size-preset menu, and whenever your graph contains a 3D generator, it becomes an interactive camera.

Resize handle

Drag the bottom-right grab handle to resize the preview between 160 and 720 pts on iPad, 120–320 on iPhone. Aspect stays 1:1. Your custom size persists across launches.

Size presets

The size menu in the preview header offers Small · Medium · Large · Fullscreen. Fullscreen reuses the same renderer, so camera state survives the transition back.

Smart quality

Render quality scales with preview size automatically — small previews render at 0.25×, large at 1.0×. GPU cost tracks the pixels you can actually see.

Reset pill

A "3D ⟲" pill fades into the top-right when the camera is non-default. Tap it — or double-tap the preview — to spring back to identity.

Camera gestures

One-finger drag

Orbits the camera. Yaw on horizontal, pitch on vertical. Pitch clamps at ±86° so you never flip upside-down.

Pinch / trackpad zoom

Changes camera distance from 0.4 to 4.0. Stacks on top of each node's per-node Camera Distance slider.

Double-tap

Springs the camera back to its default orientation with a short animation. Same effect as tapping the reset pill.

Gestures are opt-in. The camera only attaches when the graph contains at least one 3D generator. Pure 2D graphs behave exactly like before — the preview is a passive image with a resize handle. No accidental panning while you're dragging across a 2D graph on iPad.

2D & 3D, Same Graph.

Because 3D generators emit standard BGRA textures, every existing 2D modifier and combiner composes them for free. No new concepts to learn.

Chain

Modifiers still apply

Run a 3D generator through Bloom, Glitch, Edge Detect, Kaleidoscope, Color Controls, Feedback, or any other modifier. Each one treats the 3D render as its input texture.

Combine

Blend 2D with 3D

Wire Plasma + Torus Knot 3D into a Blend combiner. Or Starfield + Icosphere Terrain into Screen Blend. The 3D render is just another texture at the combiner stage.

Stack

2.5D on top of 3D

Put the Perspective 3D modifier at the end of a 3D generator chain. You now have a real 3D scene being skewed in 2.5D screen space. Unreasonably good results.

Cross-pad

Different pads, different dimensions

Each pad owns its own renderer and its own camera state. Put a 3D graph on Pad A, a 2D graph on Pad B, and the pad-blend crossfader composites them exactly like you expect.

Start From a 3D Preset.

The preset browser ships new built-ins and a showcase template so you never start from a blank canvas.

Torus Knot 3D NEW
Torus Knot 3D → Output · palette 5

Single-generator baseline. Orbit with your finger, change the palette in the inspector, swap the generator kind to Icosphere Terrain to audition both.

Icosphere Terrain 3D NEW
Icosphere Terrain → Output · palette 0

Same minimalist chain. Lower Detail for a faceted low-poly look, raise it for smooth subdivided terrain. Bass kicks turn valleys into peaks.

Knot Storm NEW
LFO → rotateY · Bass → rotateX · Beat Phase → rotateZ · Torus Knot 3D → Bloom → Output

The flagship 3D template. Three independent modulation sources drive three rotation axes simultaneously while bloom halos the whole thing. Orbit while the preset's own automation keeps running.

Bring Your Own Mesh
3D Model → Perspective 3D → Bloom → Output

Build it yourself in 30 seconds: add a 3D Model node, pick a .usdz, append Perspective 3D and Bloom. Instant gallery install for any scanned object.

3D Power Moves.

Once you're comfortable with the basics, these patterns unlock what the 3D environment is really for.

1

Orbit during a performance

Camera gestures are live. Drag the preview with one finger while audio plays to reframe the scene on the fly — the pitch/yaw persists until you double-tap to reset. Hold a key moment by pausing between tracks, then reset before the drop for a theatrical snap back to centre.

2

Two 3D generators with a Beat Crossfade

Add a Torus Knot 3D and an Icosphere Terrain 3D in the same graph, run both into a Beat Crossfade combiner in Hard Cut mode. Every beat switches between two entirely different 3D meshes — still sharing the same orbit camera, so the motion carries across the cut.

3

Perspective 3D on top of real 3D

The 2.5D Perspective 3D modifier still works downstream of a 3D generator. Put it at the very end and connect a slow LFO to its rotateY — the already-orbiting mesh now also drifts in screen-space perspective. Compound rotation reads as deep, cinematic motion.

4

Scan objects with Reality Composer

Use Object Capture on iPhone or iPad to scan a real object into a .usdz. Drop that file into a 3D Model node, wire Bass into rotateY, and you have a live 3D avatar of whatever you scanned, spinning to the music. Pair with Edge Detect for a wireframe aesthetic.

5

Camera Distance vs pinch zoom

The node's Camera Distance slider is your "framing" dolly — set it once per-node so the mesh fits the preview at rest. The pinch gesture is your "performance" zoom — stacks on top so you can push in on drops and pull out on breakdowns without losing the base framing.

6

Feedback trails on a rotating mesh

Put a Feedback modifier after a Torus Knot 3D and crank Amount to 0.9. The rotating mesh leaves long motion trails that the depth buffer still writes into — you get genuine volumetric-looking ghosts of the previous frames, not a flat smear.

7

Detail as a texture parameter

On Icosphere Terrain, Detail is mesh subdivision — 0 gives you a 20-triangle diamond, 1 is ~80 triangles, 3 is ~1300. Wire an LFO into Detail for a mesh that literally subdivides in time with the music. (Mesh rebuilds are debounced, so LFO jitter won't thrash allocation.)

8

Pair 3D with a 2D backdrop

Add a 2D Plasma + a 3D Torus Knot, run both into a Screen Blend combiner. The plasma becomes the sky, the knot becomes the hero object. Or swap in Luma Key so the 3D mesh only appears where the 2D generator is bright — a mesh that floats in and out of the music.

9

Per-pad camera state

Each pad owns its own ViewportCamera. Set Pad A to a close-up orbit of a 3D Model, Pad B to a wide shot of an Icosphere — the crossfade between pads reads as a literal camera cut between two shots of the same 3D scene.

10

Shrink the preview on heavy scenes

Imported models with thousands of triangles + Bloom + Perspective 3D can tax older devices. Drop the preview to Small — quality scale auto-lowers to 0.25×, reclaiming GPU headroom for the rest of the chain. Bump back to Large for the main drop.

Write Your Own Metal Shaders.

VKO1 1.21 introduced runtime Metal compilation. Drop a .metal file into the app, and it becomes a first-class generator node you can wire into any graph — including alongside 3D nodes, modifiers, combiners, and audio inputs. 1.22 adds true 3D custom shaders — vertex + fragment pairs running on a real mesh with depth and the shared orbit camera. Both paths live in the same library.

Two paths, one library. VKO1 1.22 gives custom shaders two entry points: classic 2D fragment shaders on a fullscreen quad (Shadertoy-style, UV in → colour out) and true 3D shaders with a user-supplied vertex + fragment pair running on a real mesh with depth buffer, MVP matrices, and the same orbit camera as the built-in 3D nodes. Pick the one that fits — you can wire both into the same graph.

The workflow

1

Start from the template

Open the node palette and tap New Shader from Scratch. VKO1 drops a working audio-reactive boilerplate into the shader library — edit it as your starting point.

2

Edit in any editor

Write Metal Shading Language in Xcode, VS Code, or Nova. Or import an existing .metal file via Files — VKO1 auto-detects the fragment function name.

3

Compile on import

Shaders compile once, at import time, via MTLDevice.makeLibrary(source:). The pipeline is cached by ID — zero per-frame cost. Compile errors show in the inspector.

4

Wire it like a generator

Your custom shader appears under My Shaders in the palette. It has Reactivity, Complexity, and Color Palette pins just like every other generator.

What the runtime gives you

VKO1 auto-injects a header with two structs before your source is compiled, so you don't have to redeclare them. This is the API surface — everything you need, nothing else.

Auto-injected header (already available in every custom shader)
#include <metal_stdlib>
using namespace metal;

struct VertexOut {
    float4 position [[position]];
    float2 uv;           // 0..1 across the render target
};

struct GeneratorUniforms {
    float  time;         // seconds since start (affected by Speed)
    float  beatPhase;    // 0..1 sweep every beat, resets to 0
    float  beatStrength; // 0..1 envelope, peaks on each beat
    float  energy;       // 0..1 overall loudness (rms)
    float2 resolution;   // render target size in pixels
    float  speed;        // Speed pin (0..2+)
    float  reactivity;   // Reactivity pin (0..1)
    float  complexity;   // Complexity pin (0..1)
    int    colorPalette; // 0..32, the selected palette index
    float  jogValue;     // jog wheel / scrub value
    float  pad1;
};
Your job: declare one fragment float4 function that takes (VertexOut in [[stage_in]], constant GeneratorUniforms &u [[buffer(0)]]) and returns a colour. That's the entire contract. Give it a unique function name — VKO1 auto-detects it from the source.

Audio-reactive inputs at a glance

UniformTypeWhat it meansWhere to use it
time float Monotonic clock in seconds, already multiplied by the Speed pin. Rotation, camera orbit, scrolling UVs.
beatPhase float 0 → 1 ramp, resets on every downbeat. Perfect sync with the host BPM. Wipes, rhythmic pulses, beat-synced fades.
beatStrength float Impulse envelope that spikes on transients and decays. Your "kick drum". Vertex/UV displacement, flash, scale pulse.
energy float Smoothed RMS loudness. Slower, bulkier than beatStrength. Brightness, colour intensity, camera distance.
reactivity float Master audio-response multiplier controlled by the Reactivity pin. Multiply every audio term by this. Lets users dial it in from 0 to full.
complexity float 0 → 1 detail knob. Semantics are up to you. Raymarch step count, octaves of noise, number of spokes.
colorPalette int 0–32. If you roll your own get_palette_color(), your shader honours the palette picker. Colour ramps, gradients, sky tint.

Example · Audio-reactive raymarched sphere

A minimal but complete 3D-looking shader: a signed-distance sphere raymarched inside the fragment shader, displaced by beat strength, orbited by time, shaded with a simple Lambert + fresnel rim, tinted from a palette. Drop this straight into VKO1 via New Shader from Scratch, paste, save.

sphere_pulse.metal · ~60 lines of working audio-reactive Metal
// VKO1 custom shader — audio-reactive raymarched sphere.
// Function name MUST match what you register in the inspector.

static float sdSphere(float3 p, float r) {
    return length(p) - r;
}

// Simple cosine-palette ramp. Swap constants for your own look.
static float3 palette(float t) {
    return float3(0.5) + float3(0.5) *
           cos(6.2831 * (float3(1.0, 0.8, 0.5) * t +
                        float3(0.10, 0.25, 0.50)));
}

fragment float4 sphere_pulse(VertexOut in [[stage_in]],
                               constant GeneratorUniforms &u [[buffer(0)]]) {

    // 1. Screen-space setup — aspect-correct, centred at origin.
    float2 uv = in.uv * 2.0 - 1.0;
    uv.x *= u.resolution.x / u.resolution.y;

    // 2. Ray origin and direction. A pinhole camera looking at -Z.
    float3 ro = float3(0.0, 0.0, 3.0);
    float3 rd = normalize(float3(uv, -1.5));

    // 3. Orbit the camera with time — this is your "3D motion" layer.
    float    a = u.time * 0.4;
    float2x2 rot = float2x2(cos(a), -sin(a), sin(a), cos(a));
    ro.xz = rot * ro.xz;
    rd.xz = rot * rd.xz;

    // 4. Audio-reactive radius. Kick drum grows the sphere.
    //    Always gate audio terms on u.reactivity so the user can dial it out.
    float pulse = u.beatStrength * u.reactivity;
    float radius = 0.9 + pulse * 0.35 + u.energy * 0.1 * u.reactivity;

    // 5. Raymarch. Complexity picks step count — users trade detail for FPS.
    int   steps = 32 + (int)(u.complexity * 48.0);
    float t     = 0.0;
    float d     = 0.0;
    bool  hit   = false;
    for (int i = 0; i < steps; ++i) {
        float3 p = ro + rd * t;
        d = sdSphere(p, radius);
        if (d < 0.001) { hit = true; break; }
        if (t > 10.0) break;
        t += d;
    }

    // 6. Background — beat-phase driven vignette so the empty space still breathes.
    float3 bg = palette(u.beatPhase * 0.2) * (0.05 + u.energy * 0.15);
    if (!hit) return float4(bg, 1.0);

    // 7. Analytic normal on a sphere is just the hit point direction.
    float3 hitPos = ro + rd * t;
    float3 n      = normalize(hitPos);

    // 8. Lambert + fresnel rim. Cheap but reads as real lighting.
    float3 L       = normalize(float3(0.6, 0.8, 0.4));
    float  diffuse = max(0.0, dot(n, L));
    float  fres    = pow(1.0 - max(0.0, dot(n, -rd)), 3.0);

    // 9. Palette-driven colour. Beat phase walks along the ramp.
    float3 base = palette(u.beatPhase + n.y * 0.3);
    float3 col  = base * (0.25 + diffuse * 0.9) + fres * base * 0.8;

    // 10. Beat-flash highlight — punches on transients, decays fast.
    col += palette(0.0) * pulse * 0.5;

    return float4(col, 1.0);
}
To use it: open the node palette → New Shader from Scratch, replace the boilerplate with this code, save. A node called sphere_pulse appears under My Shaders. Drop it into a graph, wire to Output, and play audio — the kick drum pulses the radius, energy tints the background, the camera keeps orbiting, and the Reactivity pin is your master gate.

Things to think about when building 3D-feel shaders

1

Always gate audio on u.reactivity

Every audio term — beatStrength, energy, bass buckets — should be multiplied by u.reactivity. The user expects the Reactivity pin to dial down to zero and freeze the visual. If your shader ignores it, your node feels broken inside the graph.

2

Treat complexity as a perf knob

For raymarching, wire complexity to step count. For noise, use it as octave count. Users on iPhone will keep it low; Mac users crank it. If you hard-code 128 steps, your shader will look great on M4 and tank an iPhone 12.

3

Keep u.time as your motion source

VKO1 already multiplies time by the Speed pin upstream, so the user can slow you down / speed you up from the inspector without touching your code. Don't accumulate your own time — read u.time every frame.

4

Raymarch inside a tight bounding volume

iPhone GPUs hate unbounded loops. Give your raymarch an early-out when t > 10.0 (or whatever your scene extent is) and a tiny hit epsilon like 0.001. A well-bounded march with 48 steps outperforms a sloppy one with 256.

5

beatPhase vs beatStrength — pick the right one

beatPhase is a ramp — great for wipes, rotations, smooth fades locked to the grid. beatStrength is an impulse — great for punches, flashes, vertex displacement. Using beatPhase where you want a punch feels sluggish. Using beatStrength where you want a sweep feels jittery.

6

Compose with real 3D generators downstream

Your custom fragment-shader "3D" outputs a texture like every other generator. Feed it into Perspective 3D to actually skew it in screen space. Or blend it with a real Torus Knot 3D via a combiner — your raymarched sphere becomes a backdrop for a genuine depth-buffered mesh.

7

Honour the colour palette picker

If you want your shader to feel native, either implement a get_palette_color(t, u.colorPalette) switch over indices 0..32, or copy one of the palettes from Visualizers.metal into your source. Users will change the palette from the inspector and expect the shader to respond.

8

Clamp and saturate the output

Beat flashes + fresnel + specular can easily push colours above 1.0, which then clip when Bloom or Color Controls sits downstream. A final col = col / (1.0 + col) Reinhard tone-map keeps highlights musical instead of crushed.

9

Debug by returning channels

Stuck? Return float4(n * 0.5 + 0.5, 1.0) to visualise normals, float4(float3(t / 10.0), 1.0) for depth, or float4(float3(u.beatStrength), 1.0) to confirm audio is flowing. VKO1 will recompile instantly when you re-import.

10

No CPU access — this is a sandbox

Custom shaders run inside Apple's Metal GPU sandbox. You can't read files, network, or CPU memory. You only see what the uniforms hand you. That's also why VKO1 doesn't require an Apple Developer Account to ship shaders — runtime compilation is safe by design.

Your Own Vertex & Fragment on a Real Mesh.

Everything above gets you 3D-looking shaders inside a 2D fragment. VKO1 1.22 opens the real deal: plug a user-supplied vertex + fragment pair into the same depth-buffered pipeline the built-in Torus Knot and Icosphere run on. Same orbit camera. Same mesh primitives. Same palette system. You write the shading — VKO1 hands you the geometry, the MVP matrices, and the audio uniforms.

How it differs from 2D custom shaders: you now write two functions — a vertex Vertex3DOut that transforms mesh vertices into clip space (typically via projectionMatrix * viewMatrix * modelMatrix * position), and a fragment float4 that shades them. VKO1 binds a mesh buffer with interleaved position/normal/uv, clears a depth texture, and sets up the orbit camera the user is already dragging inside the preview. You just operate on it.

The workflow

1

New 3D Shader from Scratch

Open the node palette and tap New 3D Shader from Scratch (right below the classic "New Shader from Scratch" row). VKO1 drops a working Lambert + fresnel + beat-displacement boilerplate into the library.

2

Pick a mesh

The inspector gains a Mesh picker with five primitives: Sphere, Cube, Plane (32×32 subdivided — great for terrain displacement), Torus Knot, Icosphere. Swap freely without touching the shader.

3

Edit, compile, orbit

Tap Edit Shader Code. Compile happens on save via MTLDevice.makeLibrary(source:). Both vertex and fragment function names are auto-detected. Errors show in the inspector — fix and hit save again.

4

Drag to orbit

Your 3D custom shader shares the global viewport camera with every other 3D node. Drag the preview to orbit, pinch to zoom, double-tap to reset. Wire Bass/LFO to the rotation pins for reactive spin that stacks on top of the camera.

The auto-injected 3D header

Before your source is compiled, VKO1 injects Scene3DUniforms, Vertex3DIn, Vertex3DOut and the palette helpers. You don't redeclare them. You just reach for them.

Injected automatically in every 3D custom shader
#include <metal_stdlib>
using namespace metal;

struct Scene3DUniforms {
    float4x4 modelMatrix;       // node rotations + auto-spin
    float4x4 viewMatrix;        // viewport camera (shared across 3D nodes)
    float4x4 projectionMatrix;  // perspective, aspect-correct
    float3   cameraPosition;    // world-space camera, for fresnel / specular
    float    time;              // seconds, post-Speed pin
    float    beatPhase;         // 0..1 ramp per beat
    float    beatStrength;      // transient envelope
    float    energy;            // smoothed rms
    float    speed;
    float    reactivity;        // master audio gate (0..1)
    float    complexity;
    int      colorPalette;      // 0..32
};

struct Vertex3DIn {
    float3 position [[attribute(0)]];   // mesh-local
    float3 normal   [[attribute(1)]];
    float2 uv       [[attribute(2)]];
};

struct Vertex3DOut {
    float4 position [[position]];
    float3 worldPos;
    float3 worldNormal;
    float2 uv;
    float  displacement;
};

// Cosine-gradient palette, same 32 entries as every other VKO1 generator.
static inline float3 get_palette_color_3d(float t, int paletteIndex);
Your contract: one vertex Vertex3DOut your_vert(Vertex3DIn v [[stage_in]], constant Scene3DUniforms &u [[buffer(1)]]) and one fragment float4 your_frag(Vertex3DOut in [[stage_in]], constant Scene3DUniforms &u [[buffer(0)]]). VKO1 auto-detects both function names from your source.

Example · Beat-displaced mesh with iridescent fresnel

This is the template VKO1 drops in when you tap New 3D Shader from Scratch — a complete working shader in ~40 lines. Vertex function displaces the mesh along its normal on every kick. Fragment shades with Lambert + rim fresnel and walks the palette with beat phase. Pair it with any mesh primitive and orbit.

my_3d_vertex + my_3d_fragment · the built-in 1.22 boilerplate
vertex Vertex3DOut my_3d_vertex(Vertex3DIn v [[stage_in]],
                                constant Scene3DUniforms &u [[buffer(1)]]) {
    Vertex3DOut o;

    // 1. Beat-driven displacement along the normal. Always gated by reactivity.
    float kick = u.beatStrength * u.reactivity * 0.25;
    float wave = sin(v.position.y * 4.0 + u.time * 3.0) * 0.05;
    float3 displaced = v.position + v.normal * (kick + wave);

    // 2. Transform to world space for lighting, then to clip space for rasterisation.
    float4 worldPos = u.modelMatrix * float4(displaced, 1.0);
    o.worldPos     = worldPos.xyz;
    o.worldNormal  = normalize((u.modelMatrix * float4(v.normal, 0.0)).xyz);
    o.uv           = v.uv;
    o.displacement = kick + wave;
    o.position     = u.projectionMatrix * u.viewMatrix * worldPos;
    return o;
}

fragment float4 my_3d_fragment(Vertex3DOut in [[stage_in]],
                                constant Scene3DUniforms &u [[buffer(0)]]) {
    float3 N = normalize(in.worldNormal);
    float3 L = normalize(float3(0.6, 0.8, 0.4));
    float  lambert = max(dot(N, L), 0.0);

    // Fresnel rim — world-space camera makes this genuinely view-dependent.
    float3 V = normalize(u.cameraPosition - in.worldPos);
    float  rim = pow(1.0 - max(dot(N, V), 0.0), 3.0);

    // Walk the palette with uv + beat phase + displacement for animated colour.
    float  t    = in.uv.x + u.beatPhase * 0.5 + in.displacement * 2.0;
    float3 base = get_palette_color_3d(t, u.colorPalette);

    float3 color = base * (0.35 + lambert * 0.75) + rim * 0.6;
    color += u.beatStrength * u.reactivity * base * 0.4;

    return float4(color, 1.0);
}

Tips for writing true 3D shaders

1

Start by picking the right mesh

The Plane primitive is subdivided 32×32 — perfect for terrain / wave shaders that displace along the Z normal. Icosphere is your organic blob. Torus Knot gives you that knotted tube look for free. Change from the inspector, no code edits.

2

Displace in the vertex, shade in the fragment

This is classic 3D. Beat-reactive displacement along the normal in the vertex function is cheap and physically correct. Don't try to fake it in the fragment — you'll kill your normals.

3

Pass world-space normal and position to the fragment

Multiply the normal by the upper 3×3 of modelMatrix (use float4(normal, 0)) so it doesn't pick up translation. The fragment gets proper world-space lighting with cameraPosition for fresnel — that's what makes the "3D" feel real.

4

Always u.modelMatrix * float4(pos, 1.0)

Forgetting the MVP multiply is the #1 compile-without-error mistake — you'll get a black frame because every vertex lands at the same clip-space point. The template's last line (o.position = u.projectionMatrix * u.viewMatrix * worldPos;) is mandatory.

5

Gate audio on u.reactivity

Same rule as 2D shaders: every beatStrength / energy / bass term multiplies by u.reactivity. Users expect the Reactivity pin to hard-freeze the motion, and the preview camera to still orbit the frozen mesh.

6

Use get_palette_color_3d() for native colour response

The palette picker in the inspector sets u.colorPalette. VKO1 injects get_palette_color_3d(t, u.colorPalette) for you — call it instead of hardcoding colours and your shader respects the same 32 palettes as every other node.

7

Shared depth buffer, shared camera

Multiple 3D custom shaders in the same graph render correctly and all orbit together under the single viewport camera. They compose like any other texture via combiners, so you can blend a custom shader on Sphere with a Torus Knot 3D and a bloom on top.

8

Wire rotation pins for reactive spin

Just like the built-in 3D generators, your custom 3D shader has rotationX/Y/Z input pins. Wire Bass → rotationX and LFO → rotationY and the mesh reacts on top of the user's orbit camera — two independent motion layers.

9

Debug in the vertex by returning the normal as colour

Stuck on a black screen? In the fragment, return float4(N * 0.5 + 0.5, 1.0) shows normals. If that's black too, your vertex function is outputting zeros and you forgot the MVP multiply. If it's rainbow, your shading code is wrong but the geometry is right.

10

Subdivided plane = height-field playground

The Plane primitive is flat geometry perfect for terrain, waves, cloth, tunnels. In the vertex, displace v.position.z by any noise / FFT-driven function and you've got a height-field visualiser in 20 lines. Gate the height by u.energy for room-filling audio terrain.

Available now.

The 3D Environment is in VKO1 — available on iPhone, iPad, and Apple Silicon Mac.

Get VKO1 on the App Store