Phaser AE

3D Mesh Renderer

Free packv3.0.0-63DLook

The renderer for 3D worlds, on WebGPU and WebGL2. It draws the scene before the sprites and the HUD, so a 3D game with a 2D HUD is one backend drawing two worlds, and it gives you lighting, shadows, materials and picking that are set up in a line rather than built from scratch.

This is a Free pack. Every phaser.io account can use it in their games.

Released October 14, 2026 · updated October 19, 2026

3D Mesh Renderer: a screenshot of its demo
A note about these graphics

Expansion packs don't come with graphics. This screenshot is one way of drawing what the pack does, made for its demo: none of the sprites, tiles or backgrounds in it are in the pack.

That's by design. A pack is a game's logic, mechanics and effects, and it works with whatever art you give it. Some packs draw effects, backdrops or 3D scenes of their own, but none carries a game's sprites.

So the look is yours. Make the art on phaser.io, bring your own, or let your agent draw it in code: the same pack can look like anything.

The pack

render3d

function render3d(opts: Render3dOptions = {}): Pack<{ render3d: Render3dApi }>

The render-3d pack: draws meshes3d tables with a depth buffer, one instanced call per mesh, at 4× MSAA by default. Supports WebGPU and WebGL2 backends; a 3D world produces the shared instance tables.

Helpers

LIGHTING_PRESETS

const LIGHTING_PRESETS: Record<LightingPreset, Render3dLightingConfig>

What each LightingPreset sets. Exported so a game can start from one and read what it changed, and so a tool can list them; preset() applies them.

Functions

packPattern

function packPattern(p: Pattern3d | null | undefined, rgb: (css: string) => readonly number[], out: Float32Array, at: number): void

Resolve a pattern to the 24 floats a material carries, at out[at]: [id, amount, count, scale | axis, angle (radians), pixel, seed | frame xyz, how it is measured | c0 rgb, colors | c1 rgb, 0 | c2 rgb, 0]. How it is measured: 0 the instance's scale, 1 the frame, 2 the surface (around, along in place of the frame), 3 the surface with u along. No pattern, an unknown kind or no color writes zeros (id 0: nothing drawn). rgb is the color parser.

registerMaterial3d

function registerMaterial3d( k: { registry: { add(kind: string, def: Material3dDef): void; list(kind: string): ReadonlyArray<unknown> } }, def: { name: string } & Partial<Omit<Material3dDef, 'name' | 'id'>>, ): number

Register a material and get the index instances wear. It is an index rather than a dozen per-instance floats because most objects share a handful of materials and a whole particle field shares one.

Constants

BLIT_WGSL

const BLIT_WGSL: "\n@group(0) @binding(0) var src : texture_2d<f32>;\n@vertex fn vs(@builtin(vertex_index) i : u32) -> @builtin(position) vec4<f32> {\n var p = array<vec2<f32>, 3>(vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0));\n return vec4<f32>(p[i], 0.0, 1.0);\n}\n@fragment fn fs(@builtin(position) c : v... …

The MSAA carry-in. Everything already on screen when the 3D scene starts — the backdrop, the sky, a 2D parallax — was drawn to the SWAPCHAIN, and the resolve at the end of the scene OVERWRITES the swapchain. So the frame so far is copied into the multisampled target first, the 3D scene composites over it there, and the resolve puts the whole picture back. One full-screen read at MSAA rate, and no other pack has to know that MSAA exists.

CAM_WGSL

const CAM_WGSL: "struct Cam {\n viewProj : mat4x4<f32>,\n eye : vec4<f32>,\n sunDir : vec4<f32>, // xyz = direction the light travels, w = intensity\n sunCol : vec4<f32>,\n skyCol : vec4<f32>, // w = ambient strength\n groundCol: vec4<f32>,\n fogCol : vec4<f32>,\n params : vec4<f32>, // shininess, specu... …

The shared camera uniform, as WGSL text — see the note above CAM_FIELDS.

EXT_FLOATS

const EXT_FLOATS: number

Floats in one extended-surface record. Exported so the WebGL path cannot drift from this one.

LIGHT_STRUCT_WGSL

const LIGHT_STRUCT_WGSL: "\nstruct Light {\n pos : vec4<f32>, // xyz world position, w radius\n col : vec4<f32>, // rgb colour, a intensity\n dir : vec4<f32>, // xyz spot direction, w cos(outer cone); w <= -1 is a point light\n cone : vec4<f32>, // x cos(inner cone)\n};"

A punctual light, as the shader sees it — ONE definition, shared by the mesh shader and the binning kernel.

MESH3D_CLAIM

const MESH3D_CLAIM: { readonly opaque: 1; readonly blended: 2; readonly shadow: 4; }

What a Mesh3dShaderHook says about one object, as bits. 0 is "not mine": render-3d draws it as it always has.

msDepth

const msDepth = (wgsl: string, sampleCount: number): string=> …

Swap a shader's depth declaration for the multisampled one. The READ is identical either way — textureLoad(t, coord, 0) is valid for both — so this one substitution is the whole difference, and a pack that samples the scene depth needs nothing beyond it and the layout's multisampled: true.

PATTERN3D_DEFAULTS

const PATTERN3D_DEFAULTS: Readonly<Record<Pattern3dKind, { scale: number; amount: number; count: number }>>

What a pattern leaves out, per kind.

PATTERN3D_IDS

const PATTERN3D_IDS: { readonly stripes: 1; readonly bands: 2; readonly swirl: 3; readonly rings: 4; readonly segments: 5; readonly checker: 6; readonly grid: 7; readonly dots: 8; readonly speckle: 9; readonly sprinkles: 10; readonly spots: 11; readonly grain: 12; readonly pips: 13; readonly numbers: 14; }

Every pattern and the id a material packs for it, which is what the shaders branch on (0 is none). model-3d's MODEL_PATTERNS names the same kinds in the same order for its spec, which imports nothing; a test there holds the two equal.

PATTERN3D_KINDS

const PATTERN3D_KINDS: readonly ("stripes" | "bands" | "swirl" | "rings" | "segments" | "checker" | "grid" | "dots" | "speckle" | "sprinkles" | "spots" | "grain" | "pips" | "numbers")[]

Every pattern, in id order.

patternId

const patternId = (kind: unknown): number=> …

A pattern's id, or 0 for none or an unknown kind.

SPOT_FALLOFF_WGSL

const SPOT_FALLOFF_WGSL: "\nfn spotFalloff(outer : f32, inner : f32, angleCos : f32) -> f32 {\n if (inner - outer < 1e-5) { return step(inner, angleCos); }\n return smoothstep(outer, inner, angleCos);\n}\n"

Spotlight falloff shared by every punctual-light consumer, including hard-edged cones.

SUN_BIAS_WGSL

const SUN_BIAS_WGSL: "\nfn sunBias(lightVP : mat4x4<f32>, shadow : vec4<f32>, ndl : f32) -> f32 {\n let perWidth = length(vec3<f32>(lightVP[0].x, lightVP[1].x, lightVP[2].x));\n let perDepth = length(vec3<f32>(lightVP[0].z, lightVP[1].z, lightVP[2].z));\n let slope = sqrt(max(1.0 - ndl * ndl, 0.0)) / max(ndl, 0.15);\n return shadow.... …

THE SUN'S DEPTH BIAS for one cascade, as WGSL text, exported for every pass that reads the cascades: called sunBias(cam.lightVP[c], cam.shadow, ndl), it returns the bias in that cascade's own depth units.

It is measured in TEXELS of the cascade's map — shadow.z of them, the lighting's shadowBias — and grows as the surface turns from the sun (ndl, its normal against the direction to the sun; pass 1 for a point in the air). The texel and the depth range are read off the cascade's own matrix, an orthographic projection: its first row is scaled by 1 / the half-width, its third by 1 / the depth. So a near cascade gets a fine bias and a far one a coarse one, at any scale, with nothing more in the uniform.

It was a fixed amount of depth, and a cascade's depth runs past a hundred world units, so in a model viewer it came to several centimetres: a cookie or a die cast no shadow at all, and an open pan only its rim.

TONE_WGSL

const TONE_WGSL: "\nconst TONE_KNEE = 0.8;\nfn toneMap(rgb : vec3<f32>, on : f32, exposure : f32) -> vec3<f32> {\n if (on <= 0.5) { return rgb; }\n let e = rgb * exposure;\n let over = max(e - vec3<f32>(TONE_KNEE), vec3<f32>(0.0)) / (1.0 - TONE_KNEE);\n let rolled = TONE_KNEE + (1.0 - TONE_KNEE) * (1.0 - exp(-over));\n return select(e, rolled, e > vec3<f32>(TONE_KNEE));\n}\n"

THE TONE MAP, as WGSL text, exported for the same reason the camera uniform is: every pass that draws into the 3D scene must apply the identical curve, or it sits a visible step away from the surface next to it. The deform patch, the voxel chunks and the wisps each drew with a private copy, and when the curve changed here they were left on the old one — a snow patch that followed the rider around as a grey disc against white ground, and its own comment saying the whole point was that you could not tell where one ended and the other began.

A SHOULDER: below the knee it is the identity, so a scene lands where it was authored; above it the excess rolls asymptotically to 1 and never clips. Per channel, because a highlight that keeps its ratio can never reach white — overexposure desaturates, and the knee is what keeps that away from ordinary lit surfaces. See the long note at the call site in the mesh shader.

Types

Glow3d

interface Glow3d

How one mesh glows.

  • color?: string — Halo colour, css. Default white.
  • size?: number — How far the halo reaches, in logical pixels. Default 12.
  • strength?: number — Halo brightness. Default 1.
  • rim?: number — A solid rim on the silhouette, in logical pixels. 0 for none. Default 0.

LightingPreset

type LightingPreset = 'boardgame' | 'studio' | 'noon' | 'golden' | 'overcast' | 'night' | 'dungeon'

The built-in lighting setups, by name. Each is a whole coherent scene — sun angle, colour and strength, the two ambient colours, specular, fog, shadow budget and exposure — because those dials are not independent: a bright sky with heavy ambient clips a pale palette to white however carefully the sun is set, and a shadow radius sized for a landscape spends every texel of a small board's shadow map on empty ground.

name for
boardgame a small lit table: a board, tiles, cards, pieces. Restrained, so pale palettes keep their colour.
studio a model viewer or product shot: neutral, even, no fog, shadow budget on one small subject.
noon outdoors under a high sun: hard shadows, cool sky, distance haze.
golden a low warm sun: long shadows, strong rim, warm haze.
overcast flat daylight: high ambient, weak sun, shadows barely there.
night moonlight: dim and cool, so torches and lamps from lighting-3d carry the scene.
dungeon no daylight at all: ambient near zero and sun shadows off, because the lamps ARE the light.

Material3dDef

interface Material3dDef

A surface beyond its colour: what a PBR shade needs, registered once and worn by index.

  • name: string — The registry name.
  • id: number — The index instances carry in the table's material column.
  • metallic: number — 0 is a dielectric, 1 is a metal. Default 0.
  • rough: number — 0 is a mirror, 1 is chalk. Default 0.75.
  • emissive: number — Self-illumination added unlit on top — a bulb, lava. Default 0.
  • bump: number — Normal-map strength; 0 turns the map off. Default 1.
  • normalFrame: number — Atlas frame of the normal map, or 0 for none.
  • mrFrame: number — Atlas frame of the metallic-roughness map (green rough, blue metal), or 0 for none.
  • tile: { x: number; y: number } — UV REPEAT across the surface — how many times the texture tiles over the mesh's own 0..1 uv. A floor that wants its planks at a sane size rather than one plank stretched over forty metres.
  • offset: { x: number; y: number } — UV shift, in tiles. Scrolls the texture — a conveyor, a river, a starfield on a dome.
  • unlit: boolean — Take NO lighting: the colour is exactly the tint times the texture, still fogged. A billboard drawn through this renderer is shaded like anything else, which is right for a smoke puff catching a lantern and wrong for stylised art, a world-space label, or a sprite whose art already has its own light baked in. v2 had every particle unlit until asked; v3 has them lit until asked, and this is the ask.
  • srgbColor: boolean — The base-colour map is sRGB-authored, so de-gamma it before it is shaded. The shared atlas is a LINEAR texture, which is right for everything the engine draws without lighting — a sprite, a procedural sheet, a tilemap — and wrong for a glTF base-colour map, which is authored in sRGB by the format's own rule. Read one as linear and every mid-tone comes out about 2.3× too bright: DamagedHelmet's dark teal shell washed out to pale blue. v2 sidesteps it by uploading model colour maps as a standalone -srgb texture; v3 keeps them in the one atlas and de-gammas here instead, which is the same answer with one texture rather than two. Normal and metallic-roughness maps are DATA, not colour, and stay linear — this flag is only ever set for the colour tap.
  • isotropic: boolean — Shade local lights without a surface normal, for smoke, fog and other isotropic volumes.
  • shadow: boolean — Cast the sun's shadow when drawn opaque. Default true. False for what should throw none whatever its alpha: a particle (particles-3d's materials), a marker, a glow card. A translucent instance never casts either way.
  • soft: number — SOFT: a translucent instance fades out as it closes on the geometry behind it, over this many times its own size (the smaller of its width and height), so a big smoke quad does not cut the floor with a hard line. 0, the default, is off; particles-3d's materials use its emitters' soft.
  • pattern: Pattern3d | null — MARKS PAINTED OVER THE COLOR — stripes, speckle, sprinkles, wood grain — drawn in the mesh's own frame before it is lit, so a patterned mesh stays in the batch and shades like any other. See Pattern3d. Null for none.

Mesh3dDef

interface Mesh3dDef

A mesh a world has registered for the renderer to upload: an id, a name and its interleaved vertices.

  • name: string — The registry name.
  • id: number — The id instances carry in the table's mesh column.
  • verts: Float32Array — Interleaved vertices: pos xyz, normal xyz, uv — 8 floats a vertex.
  • revision?: number — Bumped whenever the vertices change in place, which is how a CPU-skinned mesh asks to be re-uploaded.

Mesh3dShaderGlKit

interface Mesh3dShaderGlKit

What render-3d hands a Mesh3dShaderHook to draw with on WebGL2. The camera, the lighting and the sun's shadow map are the published Render3dGlBag, already filled for this frame.

  • gl: WebGL2RenderingContext — The context, with the scene's framebuffer bound and the depth test on.
  • bag: WebGl2Bag — The backend's bag: the atlas texture, and the canvas size in pixels.
  • r3: Render3dGlBag — This frame's camera, lighting and shadow map.
  • atlasSize: number — The atlas's size in pixels: a frame rect divided by this is uv.

Mesh3dShaderGpuKit

interface Mesh3dShaderGpuKit

What render-3d hands a Mesh3dShaderHook to draw with on WebGPU: everything a pipeline joining the scene pass must match, and the shared resources its shader binds. Built once a frame.

  • device: GPUDevice — The device.
  • format: GPUTextureFormat — The colour target's format.
  • sampleCount: number — MSAA samples of the scene pass: every pipeline drawn into it declares multisample: { count }.
  • depthFormat: GPUTextureFormat — The scene depth format (depth24plus).
  • uniform: GPUBuffer — The shared camera uniform, laid out as CAM_WGSL: camera, sun, ambient, fog, shadow cascades, tone map.
  • shadowView: GPUTextureView — The sun's cascades: a depth32float 2d-array; a 1×1 stand-in when shadows are off (Cam.shadow.w is then 0).
  • shadowSampler: GPUSampler — The comparison sampler (less) for shadowView.
  • atlas: GPUTextureView — The shared atlas — what an instance's frame rect points into.
  • sampler: GPUSampler — The atlas sampler (the screen policy's filtering).
  • atlasSize: number — The atlas's size in pixels: a frame rect divided by this is uv.

Mesh3dShaderHook

interface Mesh3dShaderHook

A companion that draws some meshes with a fragment shader of its OWN — mesh-shaders is the pack that does. It registers one of these under the registry kind 'mesh3dShader'; render-3d asks it about every ordinary mesh instance it draws, and an object it does not claim is drawn exactly as before. While nothing is claimed (active is 0) render-3d asks nothing at all.

A claimed object still belongs to render-3d for everything but its colour: it sorts, picks, glows and casts its shadow as the plain mesh it is, so a shader never needs a shadow pass, a pick pass or a halo of its own.

Both draw calls run INSIDE render-3d's scene pass. On WebGPU the hook sets its own pipeline, bind groups and vertex buffers and render-3d restores its own afterwards. On WebGL2 the hook uses texture units 10 and up, may change the program, the VAO, blending, culling, the depth function and polygon offset, and render-3d restores those afterwards.

  • readonly name: string — The registry name.
  • readonly active: number — How many objects are claimed now. At 0 render-3d asks nothing per object.
  • claim(slot: number): number — What happens to the object in roster slot slot, as MESH3D_CLAIM bits; 0 is not claimed.
  • plain(slot: number): number — For a claimed object: the alpha its OWN look is still drawn at, 0..1, multiplied into its tint alpha. 0 is not drawn; 1 is drawn as usual (a shader easing in over it); between, it draws translucent (easing out).
  • drawGpu(phase: 'opaque' | 'blended', pass: GPURenderPassEncoder, kit: Mesh3dShaderGpuKit, slot: number, mesh: number, inst: Float32Array): number — WebGPU: draw the claimed object in slot now, in phase ('opaque' among the opaque meshes, 'blended' in its sorted place among the transparent ones). inst is its twenty instance floats this frame, interpolated: position xyz and billboard mode, scale xyz and surface slot, yaw pitch roll and material, rgba (alpha already multiplied), and its frame's atlas rect in pixels (w = 0 untextured). Returns the draw calls it made.
  • drawGl(phase: 'opaque' | 'blended', kit: Mesh3dShaderGlKit, slot: number, mesh: number, inst: Float32Array): number — WebGL2: the same, with the same inst layout. Returns the draw calls it made.

Msdf3d

interface Msdf3d

Reading a mesh's texture as a distance field. The ink is the object's own tint, and everything outside the shape is transparent — so the mesh is the glyph, not a card with a glyph printed on it.

  • weight?: number — Shift the edge: positive is bolder, negative is lighter. 0 is the shape the font actually has. Useful range is about −0.3 to 0.3; past that the counters of a letter close up. A little extra weight often helps small text hold together against a busy surface.
  • sharpness?: number — How hard the edge is, as a multiple of the natural one-pixel softness. Default 1, which antialiases exactly as wide as a pixel. Above 1 is crisper and starts to alias; below 1 is softer.

Pattern3d

interface Pattern3d

Marks painted over a material's color. Every field but kind and colors is optional.

  • kind: Pattern3dKind — What it is, about the pattern's axis: stripes run along it, count round it; bands run across it, stacked along it; swirl winds round it (a spiral on a flat face across it); rings circle it; segments are wedges round it, split by lines scale × amount wide; checker; grid is thin lines; dots are even polka dots; speckle tiny random flecks; sprinkles short random rods; spots big soft blotches; grain wavy lines along it, as wood. pips and numbers are a DIE'S FACES: each face's pips (up to nine, then its number) or its number, read from the face its uv names (geometry-3d's dieSoup), in the first color; give them surface: { around: 1, along: 1 }. amount is how bold.
  • colors: readonly string[] — The marks' colors, any CSS color: one to three. Random kinds pick among them; the rest take turns.
  • scale?: number — World units: one repeat, or the spacing of dots, speckle, sprinkles and spots. Default per kind.
  • count?: number — stripes, segments, swirl: how many round the axis. Default per kind.
  • amount?: number — 0..1: how much of the surface the marks take — a stripe's width, a dot's size, the speckle's density.
  • axis?: 'x' | 'y' | 'z' — The mesh's own axis the pattern runs along or around. Default 'y'.
  • angle?: number — Degrees: turns the pattern about its axis; on bands, tilts them (diagonal stripes). Default 0.
  • pixel?: number — 0 is smooth; n draws it in square pixels scale / n across, edges hard — the pixel-texture look. Default 0.
  • seed?: number — A different scatter for the random kinds. Default 0.
  • frame?: readonly [number, number, number] — The size, in world units, the pattern is measured on: the mesh's own position (−0.5..0.5) times this. Give the object's REST size and the pattern sticks to it through squash and scale. Default: the instance's scale.
  • surface?: { readonly around: number; readonly along: number; readonly uAlong?: boolean } — Measure the pattern on the mesh's SURFACE instead: its uv, u around and v along (0..1 each; uAlong when the mesh has them the other way, as a tube does), unrolled onto a cylinder around round and along long in world units. So stripes, a swirl or sprinkles follow a swept tube or a bent shape however it curves, where the mesh's own position would not. The axis is the length; axis is not used. Takes the place of frame.

Pattern3dKind

type Pattern3dKind = keyof typeof PATTERN3D_IDS

One of the patterns.

PickOptions

interface PickOptions

What Render3dApi.pick was asked.

  • occlude?: boolean — What non-pickable geometry does. true (the default) is solid — it blocks clicks on pickable things behind it. false is see-through — the pick passes on to the nearest pickable mesh.

Render3dApi

interface Render3dApi

game.render3d.

  • surface(handle: unknown, opts: Surface3d | null): void — Give one mesh an extended surface — a bend, a back face, an alpha cutout, decal artwork, a surface effect — or pass null to take it back off. Every field is re-read on the call, so driving an effect's amount from a tween is a call a frame.
  • readonly surfaces: number — How many meshes are wearing an extended surface.
  • staticBatch(spec: StaticBatchSpec): StaticBatch — Pack a crowd of props that will never move into a STATIC BATCH — a forest, a city, a graveyard. It is packed once and frustum-culled on the GPU every frame, so the CPU never walks it again. Moving one means rebuild, which repacks the lot; that is the trade, made deliberately.
  • readonly staticInstances: number — Static instances packed across every batch. The GPU decides how many of them draw — see StaticBatch.
  • readonly staticDrawn: number — Static instances surviving GPU culling, asynchronously read back from the last completed frame. Includes shadow casters.
  • cull: boolean — Enable GPU frustum culling of static batches. Default true.
  • pick(x: number, y: number, opts?: PickOptions): Promise<unknown | null> — WHICH OBJECT IS UNDER THE POINTER, decided by the triangles that were actually drawn rather than by a bounding volume. x and y are logical screen pixels. Resolves after a GPU readback — about a frame — so call it from a pointer handler, and only after a frame has been drawn. It is OPT-IN: only handles with mesh3d.pickable = 1 are ever returned. occlude decides what the rest of the world does — see PickOptions. Billboards, grass, water, terrain patches, particles and static batches are not picked: a billboard's geometry is generated from the camera basis, and a static instance has no per-object identity by construction, which is the trade that makes a static batch free.
  • readonly lighting: Render3dLighting — Lighting and fog, live.
  • readonly drawCalls: number — Meshes drawn last frame.
  • readonly instances: number — Mesh instances drawn last frame.
  • clustered: 'auto' | boolean — How punctual lights are shaded. 'auto' (the default) shades a FLAT list up to a couple of dozen lights and bins them into a froxel grid past that; true and false force one or the other. The flat list loops every light at every lit fragment, which is honest and simple and does not scale: two hundred lights cost two hundred iterations at every pixel, including the two hundred that are nowhere near it. The grid divides the screen into tiles and the view depth into slices, bins each light into the cells its sphere touches, and lets a fragment loop only its own cell — usually none or one. Below the threshold the grid's own clear-and-bin is more than the loop it saves, which is why the default is a threshold rather than always-on.
  • readonly clustering: boolean — Whether the froxel grid was live last frame — what 'auto' decided.
  • near: number — Near clip. Default 0.1; follows the camera's projection (world-3d's near) whenever a 3D view is live.
  • far: number — Far clip. Default 500; follows the camera's projection (world-3d's far) whenever a 3D view is live.

Render3dBag

interface Render3dBag

What render-3d publishes each frame under ctx.backend['render3d'], so a companion pack can draw into the SAME depth pass with the SAME camera — which is how the GPU particle pool joins in without a second camera uniform to keep in step.

  • depth: GPUTextureView — The depth texture this frame's meshes were drawn against. MULTISAMPLED when sampleCount is above 1.
  • sampleCount: number — MSAA samples the 3D scene is drawn at: 1, or 4. Every pipeline drawing into colorView must declare multisample: { count } to match, and every shader SAMPLING depth must declare it as texture_depth_multisampled_2d — msDepth() does that substitution.
  • colorView: GPUTextureView — WHERE 3D CONTENT MUST DRAW — the multisampled target when MSAA is on, and the swapchain view when it is not. Drawing into bag.view instead works with MSAA off and fails validation with it on, which is the good failure: loud, immediate, and naming the attachment.
  • uniform: GPUBuffer — The main uniform: viewProj, eye, sun, ambient, fog, shadow, camRight, camUp.
  • uniformSize: number — Bytes in that uniform.
  • depthFormat: GPUTextureFormat — The depth format, for a pipeline that wants to match.
  • lights: GPUBuffer | null — The point/spot light list, so a companion surface lights from the same lamps the meshes do.
  • lightCount: number — How many entries of it are live.
  • near: number — Near clip — with far, what turns a depth sample back into a view-space distance.
  • far: number — Far clip.
  • sunScreen: { ndcX: number; ndcY: number; intensity: number; color: [number, number, number] } | null — Where the sun is ON SCREEN, or null when it is behind the camera or fully faded. God rays and the lens flare both march from it, so it is projected once here rather than twice.
  • shadows: { view: GPUTextureView; sampler: GPUSampler; cascades: number } | null — The sun's shadow cascades this frame, for a companion pass that wants to know where the sun reaches — a volumetric light march, a translucent surface. view is a depth32float 2d-array with one layer per cascade, compared through sampler (a comparison sampler, less); the world-to-cascade matrices are lightVP[c] in the shared uniform and counts.w says how many are live. Null when shadows are off.

Render3dGlBag

interface Render3dGlBag

CPU camera seam for GL companion renderers; depth is the currently bound scene target's attachment.

  • viewProj: Float32Array — WebGPU-convention camera transform; shaders remap clip depth to GL.
  • eye: { x: number; y: number; z: number; } — World-space camera position.
  • right: number[] — Camera right direction.
  • up: number[] — Camera up direction.
  • lighting: Render3dLighting — Shared live lighting and fog record.
  • near: number — Near clipping distance.
  • far: number — Far clipping distance.
  • shadow: { texture: WebGLTexture; viewProj: Float32Array; /** on, depth bias (\shadowBias` texels, in the map's depth units), texel size in uv, normal offset (one texel, world units). */ params: number[]; strength: number; }` — Directional shadow map and transform for companion surfaces, including disabled fallback texture.
  • sunScreen: Render3dBag['sunScreen'] — Projected sun for rays and flare, or null behind the camera.

Render3dLighting

interface Render3dLighting extends Render3dLightingValues

Lighting and fog, live — the Render3dLightingValues to read and write, plus the two ways to set many at once.

  • set(config: Render3dLightingConfig): void — Apply a patch: every field present is written, everything else is left alone. The way to set a scene's light in one place rather than as a column of assignments. ts game.render3d.lighting.set({ sun: { x: -0.4, y: -1, z: -0.5 }, ambient: 0.3, exposure: 0.95 });
  • preset(name: LightingPreset, over?: Render3dLightingConfig): void — Apply a named LightingPreset, then any overrides on top — a whole coherent setup in one line, with the one or two things this game wants different. ts game.render3d.lighting.preset('boardgame'); game.render3d.lighting.preset('night', { fogEnd: 90 });

Render3dLightingConfig

type Render3dLightingConfig = Partial<Render3dLightingValues>

A PATCH over the lighting: any subset of Render3dLightingValues. What set, preset and the pack's lighting option all take, so a scene's light is one object rather than nine assignments.

Render3dLightingValues

interface Render3dLightingValues

How the 3D world is lit and fogged. Every field is live: set it any frame.

  • sun: { x: number; y: number; z: number } — Direction the sun's light TRAVELS, normalised on write. Default down and slightly to the side.
  • sunColor: string — Sun colour, css. Default a warm white.
  • sunIntensity: number — Sun strength. Default 1.
  • skyColor: string — Ambient arriving from above, css. Default a cool sky blue.
  • groundColor: string — Ambient bouncing from below, css. Default a dim warm grey.
  • ambient: number — Ambient strength. Default 0.35.
  • specular: number — Specular highlight strength. Default 0.3.
  • shininess: number — Specular tightness. Default 32.
  • fogColor: string — Fog colour, css. Default matches the clear colour.
  • fogStart: number — Distance fog starts. Default 40.
  • fogEnd: number — Distance fog is total; 0 disables fog. Default 0.
  • curvature: number — Bend the world into a small GLOBE, world units of radius; 0 (the default) is flat. Every mesh vertex is drawn lower by d² / 2R, where d is its horizontal distance from the eye, so the horizon curves away on all sides (Magic Carpet's world). Only the drawn position moves: lighting, fog and every query use the true one.
  • shadows: boolean — Cast shadows from the sun. Default true.
  • shadowStrength: number — How dark a shadowed surface goes, 0..1. Default 0.65.
  • shadowRadius: number — How far from the camera shadows reach, world units. The cascades divide this between them. Default 120.
  • shadowCascades: number — How many CASCADES to split that distance into, 1 to 4. Default 3. More cascades put more texels close to the camera, where they are seen, at the cost of one depth pass each — but each pass draws far less, because a cascade only rasterises the casters that reach it.
  • shadowSize: number — Shadow map resolution, per cascade. Default 1024.
  • shadowBias: number — Depth bias that stops a surface shadowing itself, in TEXELS of the shadow map, so it holds at any scale: raise it if you see stripes, lower it if shadows detach. Default 2.
  • toneMap: boolean — Roll light that exceeds white back into range instead of clipping it flat. Without it, several bright lights on one surface saturate to paper. Default true.
  • exposure: number — Exposure applied before the roll-off. Default 1.
  • environment: number — Environment reflection multiplier, including the procedural fallback. Default 1.

Render3dOptions

interface Render3dOptions

The render-3d pack: draws meshes3d tables with a depth buffer, one instanced call per mesh. Supports WebGPU and WebGL2 backends; a 3D world produces the shared instance tables.

  • msaa?: boolean | 1 | 4 — MULTISAMPLING for the 3D scene: 1 off, 4 on. Default 4. It is the 3D scene ONLY — sprites, the HUD and the post chain are untouched, exactly as v2 scoped it. A 3D silhouette is a hard geometric edge and reads as a staircase without it; a sprite's edge came out of a texture with its own antialiasing already in it. Turn it off for a pure fill-bound scene, where the extra bandwidth buys nothing.
  • lighting?: LightingPreset | Render3dLightingConfig — The scene's LIGHT, from the manifest: a LightingPreset name, or a Render3dLightingConfig patch over the defaults. Applied at attach, before the first frame, and freely overwritten afterwards by game.render3d.lighting. Most games never need more than a name here, and a name is a whole coherent setup rather than nine dials a brief has to guess one at a time.

Static3dService

interface Static3dService

The static batch service render-3d publishes for other packs, which cannot reach game.render3d from their own attach: k.registry.get<Static3dService>('static3d', 'render-3d'). Compose the pack after render-3d.

  • name: string — Always 'render-3d'.
  • staticBatch(spec: StaticBatchSpec): StaticBatch — The same as game.render3d.staticBatch.

StaticBatch

interface StaticBatch

A packed static batch.

  • readonly count: number — How many instances it holds. NOT how many drew: the GPU decides that and writes it straight into the indirect buffer, and asking would mean a readback that stalls the very frame the batch exists to keep cheap. The count is what the CPU knows, so the count is what it reports.
  • rebuild(instances: readonly StaticInstance[]): void — Replace every instance and repack. The cost of moving a static prop, paid deliberately and in full.
  • at(i: number): StaticInstance — Read one instance back.
  • kill(): void — Remove the batch and free its buffers.
  • readonly alive: boolean — True until killed.

StaticBatchSpec

interface StaticBatchSpec

What StaticBatch is made from.

  • mesh: number — The registered mesh id every instance wears — game.world3d.mesh('tree') for a primitive, or the id registerMesh3d returned. A batch is one mesh by definition: that is what makes it one indirect draw.
  • instances: readonly StaticInstance[] — The instances, packed once.

StaticInstance

interface StaticInstance

One instance of a static batch. Only what never changes — a static prop has no alpha ramp and no blend.

  • x?: number — World position. Default 0.
  • y?: number — Height. Default 0.
  • z?: number — Depth. Default 0.
  • size?: number — Uniform size, shorthand for sx/sy/sz. Default 1.
  • sx?: number — Scale x.
  • sy?: number — Scale y.
  • sz?: number — Scale z.
  • yaw?: number — Yaw about y, radians. Default 0.
  • pitch?: number — Pitch about x, radians. Default 0.
  • roll?: number — Roll about z, radians. Default 0.
  • color?: string — Colour, css. Default white.
  • material?: number — Which registered material it wears. Default 0.
  • frame?: number — An atlas frame to wear. Default none.

Surface3d

interface Surface3d

THE EXTENDED SURFACE for one mesh — the things a flat mesh (a card, a banner, a leaf, a sail) needs and a box never does. A mesh that asks for none of them costs nothing at all: it stays on the plain pipeline. Ask for any one and it joins the extended bucket and can use all of them, because they cost two vec4s together and a card wants all of them.

  • depthOnly?: boolean — Write depth without changing colour, for hidden-line occluders.
  • transparent?: boolean — Blend texture alpha even when object alpha is 1; for soft painted halos and translucent artwork.
  • bend?: Bend3d | null — A per-instance VERTEX BEND, with lighting that follows it. Planes only — see Bend3d.
  • backFrame?: number — A DIFFERENT atlas frame on the −Y face, so one mesh is a whole card rather than the same art mirrored.
  • cutout?: number — ALPHA TEST: discard texels below this. Without it a texture's transparent regions render BLACK, because the shade multiplies rgb and ignores alpha — and a canvas cannot even carry colour in a fully transparent texel, since it stores premultiplied. Rounded corners, foliage and chain-link all need it.
  • decal?: boolean — The texture sits ON the mesh colour instead of multiplying it, so the mesh is the paper — crisp geometric corners, any colour — and the texture carries only the ink.
  • effect?: MeshFx3d | null — A surface EFFECT: foil, gilding, a burn, flames, a shine, a ripple, ice. See MeshFx3d.
  • msdf?: boolean | Msdf3d — Read the texture as a MULTI-CHANNEL SIGNED DISTANCE FIELD rather than as a picture — see Msdf3d. Artwork on a mesh is a bitmap, and a bitmap seen close is a bitmap: a number printed on a tile turns to mush the moment the camera leans in, and baking it larger only moves the distance at which it does. An MSDF stores the SHAPE's distance from each texel instead of its colour, so the edge is reconstructed at whatever size the pixel happens to be, and stays a clean curve at any zoom, any angle and any scale.
  • glow?: Glow3d | null — A HALO around the mesh, and optionally a solid rim on its silhouette. Unlike the glow surface effect, which lights the object's own pixels and stops at its edge, this exists OUTSIDE the shape — which is what a selection highlight or an enchanted weapon actually needs.