Graphics
Load shaders, create meshes, draw, and set view/projection matrices. All methods are static.
Shaders
Section titled “Shaders”Graphics.loadShader(path)
Section titled “Graphics.loadShader(path)”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.spvfile 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)}Graphics.useShader(shader)
Section titled “Graphics.useShader(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)Meshes
Section titled “Meshes”Graphics.newMesh(vertices, drawMode)
Section titled “Graphics.newMesh(vertices, drawMode)”Returns: Mesh — A new mesh.
Parameters:
-
vertices(List) — List of vertices. Each vertex is itself a list, and its length selects the layout:Length Layout Notes 5 [x, y, r, g, b]2D — z= 0,a= 16 [x, y, z, r, g, b]a= 17 [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)View & projection
Section titled “View & projection”Graphics.setViewMatrix(matrix)
Section titled “Graphics.setViewMatrix(matrix)”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).
Graphics.setProjectionMatrix(matrix)
Section titled “Graphics.setProjectionMatrix(matrix)”Parameters:
matrix— Projection matrix. Format is implementation-dependent.
Set the projection matrix.
Graphics.setViewProjectionEnabled(enabled)
Section titled “Graphics.setViewProjectionEnabled(enabled)”Parameters:
enabled(Bool) —trueto use the current view and projection matrices;falseto 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()Graphics.perViewportOverlays(on)
Section titled “Graphics.perViewportOverlays(on)”Parameters:
on(Bool) — whentrueand 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
VkViewportscales 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 cellUi.render(hud, 0, 0, Window.getWidth(), Window.getHeight()) // HUD scaled into each cellbanner.drawScreen(x, y) // screen-space text scaled into each cellnameplate.draw(worldModel) // world-space text re-projected per cameraLighting
Section titled “Lighting”Graphics.setLights(lights)
Section titled “Graphics.setLights(lights)”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()Graphics.setShadowMappingEnabled(enabled)
Section titled “Graphics.setShadowMappingEnabled(enabled)”Parameters:
enabled(Bool) —trueto render shadow maps for lights that havecastsShadows = true.
Enable or disable shadow map rendering. Has no effect on lights that don’t cast shadows.
Graphics.setShadowBias(bias)
Section titled “Graphics.setShadowBias(bias)”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).
Graphics.setShadowMapSize(size)
Section titled “Graphics.setShadowMapSize(size)”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()Global frame uniforms
Section titled “Global frame uniforms”Graphics.setWind(x, y, z, strength)
Section titled “Graphics.setWind(x, y, z, strength)”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.
Post-process effects (A#5 / A#2)
Section titled “Post-process effects (A#5 / A#2)”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 withGraphics.loadShader(e.g."shaders/mygrade").params(List) — Up to 16 numbers, delivered to the shader asplume3d_postParam(i)(i = 0isparams[0..3],i = 1isparams[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.
Graphics.clearPostEffects()
Section titled “Graphics.clearPostEffects()”Clear the post-effect list (drop back to the normal, direct-to-swapchain path).
// init(): load the post shader onceGraphics.loadShader("shaders/mygrade")
// draw(): render the scene as usual, then add the post effectGraphics.drawMesh(_mesh)Graphics.addPostEffect("shaders/mygrade", [1.0, 0.9, 0.8, 1.0]) // params[0] = a warm tintThe post shader ABI (plume3d_post.slang)
Section titled “The post shader ABI (plume3d_post.slang)”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.
| accessor | meaning |
|---|---|
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.
Opaque colour capture (A#3)
Section titled “Opaque colour capture (A#3)”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).
Graphics.opaqueCaptureEnabled(enabled)
Section titled “Graphics.opaqueCaptureEnabled(enabled)”Parameters:
enabled(Bool) —trueto 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 effectGraphics.opaqueCaptureEnabled(true)for (b in _bands) Graphics.drawMesh(b) // opaque phaseGraphics.drawMesh(_water) // transparent phase — samples the opaque copyGraphics.addPostEffect("shaders/present", []) // passthrough — capture needs the offscreen pathSampling 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:
| accessor | meaning |
|---|---|
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.
Sky, fog & ambient (A#6)
Section titled “Sky, fog & ambient (A#6)”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
setAmbientandsetFogwere 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 the0.03ambient default, fog off).
This is not fog-of-war.
Graphics.setFoghere 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.
Graphics.setAmbient(r, g, b)
Section titled “Graphics.setAmbient(r, g, b)”Parameters:
r,g,b(Num) — raw-linear ambient RGB (radiometric, matchingLight.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 RGBGraphics.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) —> 0selects exponential fogexp2(-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 atstart, fully fogged atend. Ignored whendensity > 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)Graphics.setFogHeight(baseY, falloff)
Section titled “Graphics.setFogHeight(baseY, falloff)”Parameters:
baseY(Num) — the world-space height below which geometry gains extra fog. Geometry abovebaseYis untouched by the height term.falloff(Num) — how fast that extra fog thickens per world unit of depth belowbaseY(exponential ramp).0turns height fog off (thesetFogdistance 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 horizonGraphics.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).
Graphics.setSky(shaderName, mode, params)
Section titled “Graphics.setSky(shaderName, mode, params)”Parameters:
shaderName(String) — a sky shader previously loaded withGraphics.loadShader. A sky shader#includesplume3d_sky.slang(the sky ABI — not the meshplume3d.slang) and provides only[shader("fragment")].mode(Num) —0= procedural gradient,1= environment cubemap (seesetEnvironmentMap).params(List) — up to 8Nums, shader-interpreted. The demo’s gradient shader readsparams[0..2]= horizon RGB andparams[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 RGBGraphics.setSky("shaders/sky", 0, [0.62, 0.70, 0.85, 0.0, 0.18, 0.32, 0.68, 0.0])Graphics.setEnvironmentMap(handle)
Section titled “Graphics.setEnvironmentMap(handle)”Parameters:
handle(Num) — a cube handle returned byTexture.loadCubemap(0= none).
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 rayv1 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-to-texture capture (A#7)
Section titled “Render-to-texture capture (A#7)”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.
Graphics.newCaptureTarget(w, h)
Section titled “Graphics.newCaptureTarget(w, h)”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()Graphics.newDataCaptureTarget(w, h)
Section titled “Graphics.newDataCaptureTarget(w, h)”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 fromnewCaptureTarget/newDataCaptureTarget.view(List) — the capture camera’s view matrix, aListof 16Nums (e.g.camera.getViewMatrix()).proj(List) — its projection matrix, aListof 16Nums (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.
Planar reflection (A#4)
Section titled “Planar reflection (A#4)”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’sy).
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)Graphics.setPlanarReflectionScale(scale)
Section titled “Graphics.setPlanarReflectionScale(scale)”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.
Sampling it in a shader
Section titled “Sampling it in a shader”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.
Water & camera submersion (WTR2)
Section titled “Water & camera submersion (WTR2)”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 worldY).fogR,fogG,fogB(Num) — the underwater fog / deep-water tint colour,0..1linear.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 worldY(submersion is only reported above it).surfaceY(Num) — the still-water top face worldY.fogR,fogG,fogB(Num) — the underwater fog / deep-water tint colour,0..1linear.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.
Graphics.cameraWaterState()
Section titled “Graphics.cameraWaterState()”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):
| Index | Value | Meaning |
|---|---|---|
0 | submerged | 1 if the camera is below the surface (and inside the body), else 0 |
1 | amount | smooth 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 |
2 | waterLevel | the registered surfaceY |
3–5 | fogR/G/B | the registered underwater fog colour |
6 | fogDensity | the 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.
Debug views
Section titled “Debug views”Graphics.setDebugView(mode)
Section titled “Graphics.setDebugView(mode)”Parameters:
mode(Num) — Visualization mode:
| Value | Mode |
|---|---|
0 | Off (normal rendering) |
1 | Normals |
2 | Albedo |
3 | Metallic |
4 | Roughness |
5 | Shadow map |
6 | UVs |
7 | Depth |
8 | Lighting only |
9 | Ambient |
10 | Skinning: dominant-influence weight (heat map) |
11 | Skinning: a colour per bone index |
12 | Skinning: 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) // normalsif (Input.keyJustPressed("f2")) Graphics.setDebugView(11) // colour per bone indexif (Input.keyJustPressed("f3")) Graphics.setDebugView(0) // offGraphics.setSkeletonOverlay(mode)
Section titled “Graphics.setSkeletonOverlay(mode)”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:
| Value | Mode |
|---|---|
0 | Off |
1 | Deform bones — the bones the mesh actually skins to |
2 | Deform bones + a dim rest-pose reference drawn behind them |
3 | Every 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 boneif (Input.keyJustPressed("f3")) { _skel = (_skel == null) ? 1 : (_skel >= 3 ? 0 : _skel + 1) Graphics.setSkeletonOverlay(_skel)}Graphics.setSkinnedMeshHidden(on)
Section titled “Graphics.setSkinnedMeshHidden(on)”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) —truehides every skinned mesh;falserestores 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