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.
Overview
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.
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.
Drop in a .usdz, .obj, .ply, or .abc from your Files app. Auto-centered, auto-scaled, auto-normalized. Swap files anytime from the inspector.
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.
The Generators · 3 nodes
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.
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.
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.
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.
Same as every other generator. Reactivity scales the audio response, Complexity controls mesh detail, Speed multiplies auto-rotation.
Three float pins, each 0–1 mapped to 0–2π. Wire Bass → rotateX, LFO → rotateY, Beat Phase → rotateZ for an instant audio-reactive tumble.
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.
3D Model Import
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.
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.
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.
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.
Connect the output to any modifier chain. Wire audio inputs into rotateX/Y/Z. Add Bloom, Color Controls, Perspective 3D — everything downstream still works.
Preview 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.
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.
The size menu in the preview header offers Small · Medium · Large · Fullscreen. Fullscreen reuses the same renderer, so camera state survives the transition back.
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.
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.
Orbits the camera. Yaw on horizontal, pitch on vertical. Pitch clamps at ±86° so you never flip upside-down.
Changes camera distance from 0.4 to 4.0. Stacks on top of each node's per-node Camera Distance slider.
Springs the camera back to its default orientation with a short animation. Same effect as tapping the reset pill.
Compositing
Because 3D generators emit standard BGRA textures, every existing 2D modifier and combiner composes them for free. No new concepts to learn.
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.
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.
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.
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.
Presets & Templates
The preset browser ships new built-ins and a showcase template so you never start from a blank canvas.
Single-generator baseline. Orbit with your finger, change the palette in the inspector, swap the generator kind to Icosphere Terrain to audition both.
Same minimalist chain. Lower Detail for a faceted low-poly look, raise it for smooth subdivided terrain. Bass kicks turn valleys into peaks.
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.
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.
Advanced
Once you're comfortable with the basics, these patterns unlock what the 3D environment is really for.
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.
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.
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.
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.
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.
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.
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.)
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.
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.
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.
Custom Shaders · 1.21 → expanded in 1.22
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.
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.
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.
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.
Your custom shader appears under My Shaders in the palette. It has Reactivity, Complexity, and Color Palette pins just like every other generator.
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.
#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; };
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.
| Uniform | Type | What it means | Where 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. |
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.
// 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); }
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.
u.reactivityEvery 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.
complexity as a perf knobFor 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.
u.time as your motion sourceVKO1 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.
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.
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.
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.
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.
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.
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.
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.
True 3D Custom Shaders · New in 1.22
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.
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.
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.
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.
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.
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.
Before your source is compiled, VKO1 injects Scene3DUniforms, Vertex3DIn, Vertex3DOut and the palette helpers. You don't redeclare them. You just reach for them.
#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);
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.
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.
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); }
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.
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.
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.
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.
u.reactivitySame 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.
get_palette_color_3d() for native colour responseThe 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.
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.
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.
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.
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.
The 3D Environment is in VKO1 — available on iPhone, iPad, and Apple Silicon Mac.