Skip to content

Graphics

Load shaders, create meshes, draw, and set view/projection matrices. All methods are static.

Returns: Shader or null — Loaded shader, or null if the shader could not be loaded.

Parameters:

  • path (String) — Path to the shader without extension (e.g. "shaders/triangle"). The engine loads the corresponding .spv file from the mounted filesystem.

Same path returns the same cached instance. In dev with hot-reload, changing the source may trigger a recompile.

var shader = Graphics.loadShader("shaders/triangle")
if (shader != null) {
Graphics.useShader(shader)
_mesh = Graphics.newMesh(vertices, "triangles", shader)
}

Parameters:

  • shader (Shader) — Shader to use for subsequent draw calls.

Set the current shader. Subsequent drawMesh calls use this shader unless overridden.

Graphics.useShader(myShader)
Graphics.drawMesh(_mesh)

Returns: Mesh — A new mesh.

Parameters:

  • vertices (List) — List of vertices. Each vertex is itself a list, and its length selects the layout:

    LengthLayoutNotes
    5[x, y, r, g, b]2D — z = 0, a = 1
    6[x, y, z, r, g, b]a = 1
    7[x, y, z, r, g, b, a]position + colour
    10[x, y, z, nx, ny, nz, r, g, b, a]adds normals (needed for lighting)
    12[x, y, z, nx, ny, nz, r, g, b, a, u, v]full vertex — normals + UVs

    Any other length is rejected. Use the 10- or 12-float form for lit geometry: without normals a mesh cannot be shaded. The procedural-terrain examples (Physics Mesh Terrain, Physics Heightfield) use the 12-float form.

  • drawMode (String) — One of "triangles", "trianglestrip", "lines", "linestrip", "lineloop", "points".

Graphics.newMesh(vertices, drawMode, shader)

Section titled “Graphics.newMesh(vertices, drawMode, shader)”

Returns: Mesh — A new mesh associated with the given shader.

Parameters:

  • vertices (List) — Same as above.
  • drawMode (String) — Same as above.
  • shader (Shader) — Shader to use when drawing this mesh.
_mesh = Graphics.newMesh([
[0.0, -0.5, 0.0, 1.0, 0.0, 0.0, 1.0],
[0.5, 0.5, 0.0, 0.0, 1.0, 0.0, 1.0],
[-0.5, 0.5, 0.0, 0.0, 0.0, 1.0, 1.0]
], "triangles", shader)

Graphics.drawMesh(mesh) / Graphics.drawMesh(mesh, shader) / Graphics.drawMesh(mesh, shader, modelMatrix)

Section titled “Graphics.drawMesh(mesh) / Graphics.drawMesh(mesh, shader) / Graphics.drawMesh(mesh, shader, modelMatrix)”

Parameters:

  • mesh (Mesh) — Mesh to draw.
  • shader (Shader, optional) — Override the current or mesh shader.
  • modelMatrix (optional) — Model matrix for the draw.

Draw a mesh this frame. With one argument, uses the mesh’s shader or the current shader. With two, uses the given shader. With three, also applies the model matrix.

Graphics.drawMesh(_mesh)
Graphics.drawMesh(_mesh, otherShader)

Parameters:

  • matrix — View matrix (camera world-to-view transform). Format is implementation-dependent (e.g. list or foreign type).

Set the view matrix used for subsequent drawing (when view/projection is enabled).

Parameters:

  • matrix — Projection matrix. Format is implementation-dependent.

Set the projection matrix.

Graphics.setViewProjectionEnabled(enabled)

Section titled “Graphics.setViewProjectionEnabled(enabled)”

Parameters:

  • enabled (Bool) — true to use the current view and projection matrices; false to draw without them (e.g. full-screen UI).

Enable or disable application of view and projection matrices (e.g. for 3D scene vs. UI).

Graphics.setViewProjectionEnabled(true)
Graphics.setViewMatrix(camera.getViewMatrix())
Graphics.setProjectionMatrix(camera.getProjectionMatrix(aspect))
_scene.draw()

Parameters:

  • on (Bool) — when true and the frame has split-screen viewports, the frame’s overlays are replayed into each viewport’s cell (scissored) instead of drawing once full-screen — so a per-player overlay stays inside its own region and never spills across a divider. Off (default) draws overlays once full-screen.

The one flag now covers two overlay kinds:

  • Retained-UI HUD (CAM-18, ADR 0064) — the HUD batches are built once, then replayed into each cell’s sub-rect (a sub-rect VkViewport scales the full-window HUD in + a matching scissor clips it).
  • SDF text (ADR 0105) — screen-space labels (Text.drawScreen) are scaled into each cell the same way, and world-space labels (Text.draw) are re-projected by each cell’s own camera (drawn against that viewport’s view/projection), so a world-space nameplate lands at the correct on-screen spot for each player rather than the main camera’s. Depth-test stays on (world text is occluded by that cell’s geometry).

Submit each overlay once, full-window, and the renderer handles the per-cell replay. World-space sprites remain scene content (drawn per viewport by the scene pass already). See /examples/split-hud (HUD), /examples/split-text-demo (SDF text), and ADR 0064 / ADR 0105.

Graphics.perViewportOverlays(true)
// in draw(): submit each overlay once, full-window — replayed per cell
Ui.render(hud, 0, 0, Window.getWidth(), Window.getHeight()) // HUD scaled into each cell
banner.drawScreen(x, y) // screen-space text scaled into each cell
nameplate.draw(worldModel) // world-space text re-projected per camera

Parameters:

  • lights (List) — List of Light objects to use for this frame’s draw calls.

Pass the scene lights to the renderer before calling scene.draw(). Required for lit PBR rendering; without this call the scene renders unlit.

Graphics.setLights([_sunLight, _fillLight])
_scene.draw()

Parameters:

  • enabled (Bool) — true to render shadow maps for lights that have castsShadows = true.

Enable or disable shadow map rendering. Has no effect on lights that don’t cast shadows.

Parameters:

  • bias (Num) — Shadow acne bias (e.g. 0.002). Too low causes shadow acne; too high causes shadows to detach from surfaces (Peter-panning).

Parameters:

  • size (Num) — Shadow map resolution in pixels (e.g. 512, 1024, 2048, 4096). Larger values improve shadow quality at higher GPU cost.
// In draw():
Graphics.setLights([_sun])
Graphics.setShadowMappingEnabled(true)
Graphics.setShadowBias(0.002)
Graphics.setShadowMapSize(2048)
_scene.draw()

Parameters:

  • x, y, z (Num) — Wind direction. Need not be normalized.
  • strength (Num) — Scalar that scales the wind; shaders read it separately from the direction.

Set the global wind for the frame. The wind is one field of a small frame-uniform block the engine binds at set 0, binding 4 for every draw: { time, delta, frameIndex, wind }. Time, delta and the frame index are host-owned (a monotonic per-frame clock); the wind is the field the app sets, and it persists until changed.

This block is the prerequisite for animated shaders — grass and tree wind sway, water waves, moving clouds, colour pulses — anything that has to advance on its own clock. A shader reads it by #include "plume3d.slang" and calling plume3d_time() / plume3d_delta() / plume3d_frameIndex() / plume3d_windDir() / plume3d_windStrength(); no per-draw uniform plumbing is needed.

// once, or whenever the wind changes:
Graphics.setWind(1.0, 0.0, 0.0, 1.0) // blow along +X at unit strength
#include "plume3d.slang"
// in the vertex shader — sway the tip by the frame clock × wind:
float sway = sin(plume3d_time() * 2.0 + p.x * 3.0) * plume3d_windStrength() * 0.25;
p.x += sway;

See the wind-sway example.

Run a full-screen post-process over the rendered frame. When at least one post effect is added, the engine renders the scene to an offscreen colour target plus a sampleable depth target, then runs your post shader full-screen — sampling the scene colour, the scene depth, the frame clock, and your params — and writes the result to the swapchain. This is how you do colour grading, depth-based fog / underwater, outlines, and any screen-space effect. Opt-in: with no post effect added the normal render path is unchanged (engine PLM-240 / PLM-237, ADR 0074).

Graphics.addPostEffect(shaderName, params)

Section titled “Graphics.addPostEffect(shaderName, params)”

Parameters:

  • shaderName (String) — A post shader previously loaded with Graphics.loadShader (e.g. "shaders/mygrade").
  • params (List) — Up to 16 numbers, delivered to the shader as plume3d_postParam(i) (i = 0 is params[0..3], i = 1 is params[4..7], …).

Add a post effect for this frame. The post list is per-frame, like a draw call — call it in draw() after drawing your scene, every frame you want the effect.

Stacking (multiple effects). Call addPostEffect more than once to run a chain: the effects execute in the order you add them, each one’s output feeding the next one’s input, and the last effect writes the screen. So you can compose independent passes — e.g. underwater fog, then a rain overlay, then a colour grade — instead of cramming them into one shader (up to 8 in a chain). Each stage samples its input at plume3d_sceneColor(uv) (the raw scene for the first effect, the previous effect’s result for later ones); the scene depth and the frame block are the original scene’s for every stage (engine ADR 0101). See the Post Stack demo.

Clear the post-effect list (drop back to the normal, direct-to-swapchain path).

// init(): load the post shader once
Graphics.loadShader("shaders/mygrade")
// draw(): render the scene as usual, then add the post effect
Graphics.drawMesh(_mesh)
Graphics.addPostEffect("shaders/mygrade", [1.0, 0.9, 0.8, 1.0]) // params[0] = a warm tint

A post shader #include "plume3d_post.slang" — the post ABI, shipped at engine/shaders/include/plume3d_post.slang — and provides only [shader("fragment")] (the full-screen vertex is in the include; PostVSOutput carries a UV). Do not #include "plume3d.slang" in a post shader — that is the mesh ABI, a different set-0 layout.

accessormeaning
float4 plume3d_sceneColor(uv)the rendered scene colour
float plume3d_sceneDepth(uv)scene depth [0,1], near → far (read by texel .Load(), so it is exact — do not .Sample the depth image)
float plume3d_time()the frame clock (for animated grades — shared with the frame block)
float4 plume3d_postParam(i)your params from addPostEffect

The engine binds these at set 0: binding 0 = sceneColor, binding 1 = sceneDepth, binding 2 = a shared SamplerState, binding 3 = the frame block, binding 4 = the params (separate Texture2D + SamplerState, MoltenVK-safe).

#include "plume3d_post.slang" // the POST ABI — not plume3d.slang
[shader("fragment")]
float4 fragment(PostVSOutput input) : SV_Target {
float4 c = plume3d_sceneColor(input.UV);
float d = plume3d_sceneDepth(input.UV); // 0 (near) .. 1 (far)
float4 tint = plume3d_postParam(0); // params[0] from addPostEffect
return float4(c.rgb * tint.rgb, 1.0);
}

v1 limits: a single effect is applied (a multi-effect ping-pong chain is a follow-up); the offscreen colour is LDR (B8G8R8A8_SRGB, so existing mesh shaders render into it unchanged; HDR + tonemap/bloom is a follow-up); and post effects run for the single-camera path only — they are skipped when fog-of-war, split-screen, or the RTT composite path owns the frame.

See the post-effects & scene-depth guide and the post-depth example.

Let a transparent draw (water, glass, heat haze) sample the opaque scene behind it and distort it — refraction. A shader cannot read the colour target it is drawing into, so the engine captures the opaque scene into a separate texture before the transparent draws run: with capture on, the offscreen scene render splits into opaque → copy → transparent, and transparent meshes sample the captured opaque colour at a screen-space UV (engine PLM-238 / ADR 0075).

Parameters:

  • enabled (Bool) — true to split the scene render into an opaque phase, a copy into the capture texture, and a transparent phase that can sample it. false (default) renders the scene in one pass.

Enable opaque-colour capture for the frame. Call it in draw() (like a draw call). A surface only samples the capture if it is transparent — set mesh.blendMode = "alpha" (or a translucent material); an opaque draw would read itself, which is why the phases are split.

Requires the offscreen path. Opaque capture only takes effect when the scene renders offscreen — pair it with at least one Graphics.addPostEffect (a passthrough is fine), single-camera. Without a post effect the capture binding stays the 1×1 white default and a refraction shader reads white (no refraction) — inert, never a crash.

// draw(): opaque scene, then the transparent surface; enable capture + a post effect
Graphics.opaqueCaptureEnabled(true)
for (b in _bands) Graphics.drawMesh(b) // opaque phase
Graphics.drawMesh(_water) // transparent phase — samples the opaque copy
Graphics.addPostEffect("shaders/present", []) // passthrough — capture needs the offscreen path

Sampling the capture (plume3d_opaqueColor)

Section titled “Sampling the capture (plume3d_opaqueColor)”

A transparent mesh shader reads the capture through the mesh ABI (#include "plume3d.slang" — not the post ABI) at set 0, binding 6:

accessormeaning
float4 plume3d_opaqueColor(screenUv)the captured opaque scene colour — a combined Sampler2D, read with .Sample()

Sample with a screen-space UV (fragment position ÷ screen size, or NDC · 0.5 + 0.5 passed from the vertex stage) — the capture is screen-space, so a model-space UV won’t line up with the scene behind the surface. Offset the UV to refract. Binding 6 never collides with the lighting/shadow bindings (1..3); it is a colour texture, so .Sample() — unlike scene depth, which is read by .Load().

#include "plume3d.slang" // the MESH ABI — not plume3d_post.slang
[shader("fragment")]
float4 fragment(VSOutput input) : SV_Target { // input.ScreenUV = NDC·0.5+0.5 from the vertex stage
float2 wobble = float2(sin(input.ScreenUV.y * 30 + plume3d_time() * 3), 0) * 0.03;
float4 behind = plume3d_opaqueColor(input.ScreenUV + wobble);
return float4(behind.rgb * float3(0.65, 0.82, 1.0), 1.0); // cool water tint
}

See the opaque-colour refraction guide and the refraction example.

Neutral, raw-linear atmosphere knobs — an ambient fill, a global distance fog, a sky drawn behind the scene, and an environment cubemap. The engine ships the pass and the knobs; the sky shader and the time-of-day / weather curve are pack/game content (engine PLM-241 / ADR 0076). Every knob is additive and defaults to a no-op, so a scene that never calls them renders exactly as before.

Now effective (A#6 follow-up, ADR 0079). In the first A#6 build setAmbient and setFog were silently inert — a renderer bug built the lit shader’s lighting UBO from the defaults, so neither knob reached the shader. The follow-up wires them through, so both now actually apply. A scene that never calls them is unchanged (still the 0.03 ambient default, fog off).

This is not fog-of-war. Graphics.setFog here is atmospheric distance fog — it fades geometry by camera distance. It is a different feature from the gameplay fog-of-war (Graphics.fogSoftness / FogOfWar.*, ADR 0067), which hides unexplored world. Do not conflate the two.

Parameters:

  • r, g, b (Num) — raw-linear ambient RGB (radiometric, matching Light.color / energy()).

Set the global ambient fill that lifts unlit / shadowed faces off black. This replaces the old hardcoded 0.03 shader constant; the default is (0.03, 0.03, 0.03), so a scene that never calls it is pixel-identical to before. A lit shader reads it from the LightingData UBO tail (set 0 binding 1); setDebugView(9) (“Ambient”) isolates its contribution.

Graphics.setAmbient(0.34, 0.37, 0.44) // raw linear RGB

Graphics.setFog(r, g, b, density, start, end)

Section titled “Graphics.setFog(r, g, b, density, start, end)”

Parameters:

  • r, g, b (Num) — fog colour (raw linear).
  • density (Num) — > 0 selects exponential fog exp2(-density · dist); 0 (or less) selects linear fog over [start, end].
  • start, end (Num) — for linear fog, the camera-distance range in world units: clear at start, fully fogged at end. Ignored when density > 0.

Set the atmospheric distance fog. It is applied as a term in the lit fragment shader (not the post stack), so it composes with every scene path — including fog-of-war scenes, where a post-stack fog would silently vanish.

Graphics.setFog(0.62, 0.70, 0.85, 0.0, 8.0, 30.0) // linear: clear to 8, fully fogged by 30
// or exponential:
Graphics.setFog(0.6, 0.7, 0.85, 0.04, 0.0, 0.0)

Parameters:

  • baseY (Num) — the world-space height below which geometry gains extra fog. Geometry above baseY is untouched by the height term.
  • falloff (Num) — how fast that extra fog thickens per world unit of depth below baseY (exponential ramp). 0 turns height fog off (the setFog distance fog still applies).

Enable exponential height fog — the aerial-perspective term — layered on top of the distance fog from setFog. Geometry below baseY gains extra fog ramped by falloff, so the ground and low, receding terrain haze into the sky at the horizon and there is no hard floor↔sky seam. The term is applied per-fragment in the lit shader (a world-height weight), composited over the distance fog.

Set the fog colour (setFog) to the sky’s horizon colour for a seamless aerial blend — the ground then dissolves into the very colour the sky shows at the horizon, instead of fading to an unrelated fog tint.

Graphics.setFog(0.62, 0.70, 0.85, 0.0, 22.0, 60.0) // fog colour = the sky horizon
Graphics.setFogHeight(0.5, 0.18) // haze everything below y=0.5, ramp 0.18 / unit
// Graphics.setFogHeight(0.0, 0.0) // height fog off (distance fog stays on)

The height term uses a per-fragment world-height weight (no camera position), which is visually sufficient for the ground→sky blend; an exact view-ray aerial-perspective integral is a documented follow-up (ADR 0079).

Parameters:

  • shaderName (String) — a sky shader previously loaded with Graphics.loadShader. A sky shader #includes plume3d_sky.slang (the sky ABI — not the mesh plume3d.slang) and provides only [shader("fragment")].
  • mode (Num) — 0 = procedural gradient, 1 = environment cubemap (see setEnvironmentMap).
  • params (List) — up to 8 Nums, shader-interpreted. The demo’s gradient shader reads params[0..2] = horizon RGB and params[4..6] = zenith RGB.

Draw a full-screen sky behind the scene at z=far. It is drawn first with depth test/write off, so opaque geometry always overwrites it — the sky never occludes anything. A real perspective camera is required: the sky reconstructs a world-space view ray per pixel from the inverse view/projection.

Graphics.loadShader("shaders/sky") // once, in init()
// procedural gradient (mode 0): horizon RGB then zenith RGB
Graphics.setSky("shaders/sky", 0, [0.62, 0.70, 0.85, 0.0, 0.18, 0.32, 0.68, 0.0])

Parameters:

Point the cubemap sky (setSky mode 1) at an environment cube. The cube is sampled by the sky’s per-pixel view ray.

var cube = Texture.loadCubemap([
"textures/cube/px.png", "textures/cube/nx.png",
"textures/cube/py.png", "textures/cube/ny.png",
"textures/cube/pz.png", "textures/cube/nz.png"])
Graphics.setEnvironmentMap(cube)
Graphics.setSky("shaders/sky", 1, []) // mode 1 = sample the cube by the view ray

v1 limits. The sky pass reaches the single-viewport post/offscreen path and the plain path; fog-of-war / RTT-composite / split-screen scenes get the fog (a lit-shader term) but not the sky pass yet. The cubemap sky renders, but sampling the env cube from lit shaders for reflections (IBL) is a follow-up — a combined SamplerCube in the crowded mesh set-0 fails MoltenVK MSL conversion (ADR 0076 §5). Loading a sky shader also attempts one harmless mesh-pipeline build that is skipped on MoltenVK — expect a single Could not create graphics pipeline log line for the sky shader, not per-frame spam.

See the sky, fog & ambient guide and the sky demo.

Render the scene from a second camera into a persistent, sampleable texture — an in-world monitor, security camera, mirror or picture-in-picture, and (later) interaction maps for grass-bending and water displacement. You allocate a capture target once, submit a scene render into it from a view/projection each frame, and then sample the result like any loaded texture (engine PLM-242 / ADR 0077). A capture reuses the existing offscreen render pass and the custom-material set 5 sampling path — there is no new descriptor binding, and the capture is a Texture, so Mesh.setCustomTexture binds it exactly like a Texture.load handle.

Parameters:

  • w, h (Num) — target size in pixels.

Allocate a persistent sRGB colour capture target and return it as a Texture (null on allocation failure — an oversized target fails gracefully, never a crash). This is the colour view: the captured scene is sRGB-encoded, ready to display on a monitor / mirror / PiP panel. Allocate it once (in init()) and reuse it — a target lives until engine teardown, and v1 has no per-target release, so allocating one per frame or per level leaks GPU memory (ADR 0077 follow-up).

_capture = Graphics.newCaptureTarget(400, 400) // once, in init()

Parameters:

  • w, h (Num) — target size in pixels.

The same as newCaptureTarget, but the returned Texture samples the UNORM / linear view — use it for data targets (interaction / displacement / grass-bending maps) where you do not want sRGB encoding applied to the stored values. Colour → newCaptureTarget; data → newDataCaptureTarget.

Graphics.submitCapture(target, view, proj)

Section titled “Graphics.submitCapture(target, view, proj)”

Parameters:

  • target (Texture) — a capture target from newCaptureTarget / newDataCaptureTarget.
  • view (List) — the capture camera’s view matrix, a List of 16 Nums (e.g. camera.getViewMatrix()).
  • proj (List) — its projection matrix, a List of 16 Nums (e.g. camera.getProjectionMatrix(aspect)).

Render the whole scene from view / proj into target for this frame. Call it in draw(), not update() — the frame clears pending submissions between update() and draw(), so a capture submitted in update() is wiped before it records. Bind the two matrices to locals first (don’t nest the foreign getters inline as arguments) so each lands in its own argument slot:

draw() {
var view = _capCam.getViewMatrix()
var proj = _capCam.getProjectionMatrix(1.0) // aspect of the capture target
Graphics.submitCapture(_capture, view, proj) // render the scene into the target
_scene.draw()
// ... draw the rest of the scene ...
_panel.setCustomTexture(0, _capture) // sample the capture at set-5 slot 0
Graphics.drawMesh(_panel)
}

The capture camera must be a real scene camera (from Scene.addCamera): a bare Camera.new() has a null backing and its getViewMatrix() is inert, so submitCapture silently drops. It never has to be the active view — it exists only to supply the capture’s view/proj.

v1 limits. A capture is single-camera — one submitted capture per frame (it borrows the single-viewport UBO slots, so captures are skipped under split-screen / RTT-composite). submitCapture records the whole scene, including any screen-space overlay — so a monitor that is itself in the scene it captures shows the expected live video-feedback recursion; a drawSubset filter to exclude overlays (and to capture a named layer only, for interaction maps) is a documented follow-up (ADR 0077).

See the RTT monitor example.

Reflect the scene in a horizontal water or floor plane. The engine renders the scene once more from the main camera mirrored across the plane y = planeY into a half-res reflection target, exposed globally at mesh set 0 binding 7 — a water/floor shader (#include "plume3d.slang") samples it screen-space with plume3d_reflection(screenUv) and gets the mirror of what is above the water at that pixel (engine PLM-239 / ADR 0078). It clones the opaque-colour capture binding-6 machinery for a new binding 7: a global always-available sampler, so any lit/water shader reads the reflection with no per-draw setCustomTexture wiring. Until reflection is enabled the binding is a 1×1 white default, so the surface reads white — inert, never a crash.

Graphics.setPlanarReflection(enabled, planeY)

Section titled “Graphics.setPlanarReflection(enabled, planeY)”

Parameters:

  • enabled (Bool) — turn planar reflection on / off.
  • planeY (Num) — the mirror plane’s world-space height (the water/floor surface’s y).

Enable planar reflection and set the mirror-plane height. Sticky state (like sky / ambient — it persists until you change it), single-camera. Cost is a second opaque scene draw (the mirrored pass renders the opaque phase only). Disabling it frees the reflection target’s VRAM (it is not held to teardown).

// A water plane at y=0 reflects the scene above it.
Graphics.setPlanarReflection(true, 0.0)

Parameters:

  • scale (Num) — reflection-target size as a fraction of the swapchain, in (0, 1]. 0.5 (the default) is half-res.

Set the reflection target’s resolution as a fraction of the swapchain. The default 0.5 (half-res) keeps the extra draw cheap and is usually indistinguishable on a rippled surface; raise it toward 1.0 for a sharper mirror at more cost. An oversized/failed target simply skips the reflection (white), never crashes.

A water/floor shader includes the shared mesh ABI and reads binding 7 at its own screen UV — computed from the clip-space position, because the reflection was rendered from the mirrored camera and already holds, at each screen pixel, the mirror of what is above the water there:

#include "plume3d.slang" // b0 view/proj + b7 reflection
[shader("fragment")]
float4 fragment(VSOutput i) : SV_Target {
// screen UV from the clip position (perspective divide → NDC → [0,1]); Y is NOT flipped —
// the reflection used the same Y-flipped projection as the main view, so the grids align.
float2 screenUv = i.ClipPos.xy / i.ClipPos.w * 0.5 + 0.5;
float3 refl = plume3d_reflection(screenUv).rgb;
float3 col = lerp(float3(0.06, 0.12, 0.20), refl, 0.75); // water tint under the reflection
return float4(col, 1.0);
}

Key notes (load-bearing):

  • The reflector must be transparent (mesh.blendMode = "alpha") so it is excluded from its own reflection — the reflection renders the opaque phase only, and an opaque reflector sitting on the mirror plane would occlude everything above it from the mirrored camera below.
  • v1 is single-camera (one reflection plane, skipped under split-screen / composite).
  • The reflected content should be above the plane; oblique below-plane clipping of opaque geometry beneath the water is a documented follow-up (ADR 0078), as are a per-material winding-flip variant for explicitly culled reflected meshes, an HDR reflection format, and multiple reflection planes.

See the planar reflection example.

Register the active water surface with the renderer so it can answer “is the camera under the water, and how deep?” — the state a game uses to cross-fade an underwater post effect, place a waterline meniscus, and (later) drive a dedicated underwater pass (engine Block WTR2 — Water Fidelity, ADR 0094). This is renderer state, sticky until changed, exactly like setFog / setSky / setPlanarReflection. It pairs with the Water class (the CPU wave mirror for buoyancy) but is independent of it — this API is about where the surface is and whether you are below it, not about the waves.

Graphics.setWaterPlane(surfaceY, fogR, fogG, fogB, fogDensity)

Section titled “Graphics.setWaterPlane(surfaceY, fogR, fogG, fogB, fogDensity)”

Register an infinite flat water surface at world height surfaceY, with the underwater fog colour and density to report back once the camera dips below it. Call it once (or whenever the water level changes); it stays in effect until replaced.

Parameters:

  • surfaceY (Num) — the still-water surface height (the water plane’s world Y).
  • fogR, fogG, fogB (Num) — the underwater fog / deep-water tint colour, 0..1 linear.
  • fogDensity (Num) — the underwater fog density the game feeds its underwater post effect.
// A level ocean at y = 0 with a blue-green underwater fog.
Graphics.setWaterPlane(0.0, 0.05, 0.24, 0.32, 0.5)

Graphics.setWaterVolume(minX, minZ, maxX, maxZ, minY, surfaceY, fogR, fogG, fogB, fogDensity)

Section titled “Graphics.setWaterVolume(minX, minZ, maxX, maxZ, minY, surfaceY, fogR, fogG, fogB, fogDensity)”

Register a bounded water box instead of an infinite plane: an XZ footprint [minX, minZ]..[maxX, maxZ], a floor at minY, a top face at surfaceY, plus the underwater fog. cameraWaterState then reports submerged only inside the box (below surfaceY, above minY, and within the XZ footprint) — the way to drive underwater for a pool, lake, or tank rather than a whole-world ocean. Like setWaterPlane it is sticky renderer state (a single active water body — the last setWaterPlane / setWaterVolume wins) and stays until replaced.

Parameters:

  • minX, minZ, maxX, maxZ (Num) — the box’s world-XZ footprint (min/max corners).
  • minY (Num) — the box floor world Y (submersion is only reported above it).
  • surfaceY (Num) — the still-water top face world Y.
  • fogR, fogG, fogB (Num) — the underwater fog / deep-water tint colour, 0..1 linear.
  • fogDensity (Num) — the underwater fog density the game feeds its underwater post effect.
// A 20×20 pool from y = -4 up to the waterline at y = 0.5, with a green underwater fog.
Graphics.setWaterVolume(-10, -10, 10, 10, -4.0, 0.5, 0.03, 0.20, 0.16, 0.6)

Authored .blend water registers this for you: a plume3d_water_volume Blender box auto-calls setWaterVolume from its world AABB (engine WTR2 #3, ADR 0097), so a level designer’s tagged pool drives camera submersion with no script at all — this method is the manual equivalent.

Returns: List — [submerged, amount, waterLevel, fogR, fogG, fogB, fogDensity] for the current camera, computed engine-side from the registered water surface and the camera position (recovered from the active view matrix — no Wren-side math needed):

IndexValueMeaning
0submerged1 if the camera is below the surface (and inside the body), else 0
1amountsmooth 0→1 submersion depth ((surfaceY − camY) / transitionBand, clamped) — the value to cross-fade effects on, so the transition through the surface is not a hard cut
2waterLevelthe registered surfaceY
3–5fogR/G/Bthe registered underwater fog colour
6fogDensitythe registered underwater fog density

Read it each frame and drive the underwater look from it — for example, swap the present pass for an underwater post effect once amount climbs, feeding it the reported fog:

var ws = Graphics.cameraWaterState() // [submerged, amount, level, fogR, fogG, fogB, fogDensity]
if (ws[1] > 0.15) {
// fade the underwater post in with the submersion amount
Graphics.addPostEffect("shaders/underwater", [ws[3], ws[4], ws[5], ws[6], /* … */])
} else {
Graphics.addPostEffect("shaders/present", [])
}

The underwater effect itself is game-side content — a post effect the game adds and cross-fades on amount; the engine only supplies the submersion state. See the Water Playground (apps/water_playground) for the full pattern (free-fly dive, the waterline meniscus quad, and the cross-faded underwater post), and the Water Demo for the simpler scripted dip.

Parameters:

  • mode (Num) — Visualization mode:
ValueMode
0Off (normal rendering)
1Normals
2Albedo
3Metallic
4Roughness
5Shadow map
6UVs
7Depth
8Lighting only
9Ambient
10Skinning: dominant-influence weight (heat map)
11Skinning: a colour per bone index
12Skinning: influence count per vertex

Modes 1–9 are implemented by the engine’s lit shaders. Modes 10–12 are the skinning views and are implemented per-shader — a shader that declares no bone attributes simply ignores them; the reference toon/skinning shaders render unskinned vertices dark magenta so an unskinned mesh is unmistakable.

Mode 11 is the mis-binding test: a hand tinted with a leg bone’s colour means the vertex-group → bone mapping is wrong, whereas the right colour in the wrong place means the binding is fine and the bone matrix is not.

if (Input.keyJustPressed("f1")) Graphics.setDebugView(1) // normals
if (Input.keyJustPressed("f2")) Graphics.setDebugView(11) // colour per bone index
if (Input.keyJustPressed("f3")) Graphics.setDebugView(0) // off

Draw each skinned node’s bone hierarchy as screen-space lines over the scene, from the engine’s own runtime bone matrices.

Parameters:

  • mode (Num) — Overlay mode:
ValueMode
0Off
1Deform bones — the bones the mesh actually skins to
2Deform bones + a dim rest-pose reference drawn behind them
3Every bone, including IK targets and control bones

This is the only view of a skinned character that does not pass through the skinning shader, which is what makes it a diagnostic rather than a decoration: if the lines form a sane figure while the mesh is mangled, the pose is right and the skinning is wrong; if the lines are mangled, the pose itself is wrong. Mode 2 sharpens that further — rest and animated are projected by identical code and differ only in which matrix they read, so a correct-looking rest pose beside a broken animated one localises the fault to the runtime bone matrices.

Line colour encodes hierarchy depth, so a chain hanging off the wrong parent shows up as a colour discontinuity rather than something you have to measure.

Modes 1/2 matter on dense control rigs: an AutoRigPro humanoid carries ~344 bones of which only ~68 deform the mesh, and drawing all of them is an unreadable tangle. A filtered bone links to its nearest visible ancestor, so the filter thins the skeleton instead of breaking it into disconnected stubs.

// F3 cycles: off -> deform -> deform + rest -> every bone
if (Input.keyJustPressed("f3")) {
_skel = (_skel == null) ? 1 : (_skel >= 3 ? 0 : _skel + 1)
Graphics.setSkeletonOverlay(_skel)
}

Hide skinned meshes while still posing them. Pair it with setSkeletonOverlay to read the bone lines on their own — the difference between “the skeleton looks roughly right” and being able to see a limb pointing the wrong way.

Parameters:

  • on (Bool) — true hides every skinned mesh; false restores them.

The armature is still evaluated, so the overlay shows exactly the pose the hidden mesh would have been skinned with. Only skinned meshes are affected; static geometry still draws, which keeps the scene as a size and position reference.

Graphics.setSkinnedMeshHidden(true)
Graphics.setSkeletonOverlay(2) // bones only, animated + rest reference