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'smaterialcolumn.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-srgbtexture; 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. SeePattern3d. 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'smeshcolumn.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 declaresmultisample: { count }.depthFormat: GPUTextureFormat— The scene depth format (depth24plus).uniform: GPUBuffer— The shared camera uniform, laid out asCAM_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.wis then 0).shadowSampler: GPUSampler— The comparison sampler (less) forshadowView.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 slotslot, asMESH3D_CLAIMbits; 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 inslotnow, inphase('opaque'among the opaque meshes,'blended'in its sorted place among the transparent ones).instis 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 sameinstlayout. 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:stripesrun along it,countround it;bandsrun across it, stacked along it;swirlwinds round it (a spiral on a flat face across it);ringscircle it;segmentsare wedges round it, split by linesscale × amountwide;checker;gridis thin lines;dotsare even polka dots;speckletiny random flecks;sprinklesshort random rods;spotsbig soft blotches;grainwavy lines along it, as wood.pipsandnumbersare 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'sdieSoup), in the first color; give themsurface: { around: 1, along: 1 }.amountis 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 ofdots,speckle,sprinklesandspots. 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; onbands, tilts them (diagonal stripes). Default 0.pixel?: number— 0 is smooth; n draws it in square pixelsscale / nacross, 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,uaround andvalong (0..1 each;uAlongwhen the mesh has them the other way, as a tube does), unrolled onto a cylinderaroundround andalonglong 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;axisis not used. Takes the place offrame.
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.falseis 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 passnullto take it back off. Every field is re-read on the call, so driving an effect'samountfrom 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 meansrebuild, 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 — seeStaticBatch.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.xandyare 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 withmesh3d.pickable = 1are ever returned.occludedecides what the rest of the world does — seePickOptions. 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;trueandfalseforce 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'snear) whenever a 3D view is live.far: number— Far clip. Default 500; follows the camera's projection (world-3d'sfar) 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 whensampleCountis above 1.sampleCount: number— MSAA samples the 3D scene is drawn at: 1, or 4. Every pipeline drawing intocolorViewmust declaremultisample: { count }to match, and every shader SAMPLINGdepthmust declare it astexture_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 intobag.viewinstead 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 — withfar, 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.viewis a depth32float 2d-array with one layer per cascade, compared throughsampler(a comparison sampler, less); the world-to-cascade matrices arelightVP[c]in the shared uniform andcounts.wsays 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 namedLightingPreset, 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: aLightingPresetname, or aRender3dLightingConfigpatch over the defaults. Applied at attach, before the first frame, and freely overwritten afterwards bygame.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 asgame.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 idregisterMesh3dreturned. 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 — seeBend3d.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. SeeMeshFx3d.msdf?: boolean | Msdf3d— Read the texture as a MULTI-CHANNEL SIGNED DISTANCE FIELD rather than as a picture — seeMsdf3d. 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 theglowsurface 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.


