Skip to content

Latest commit

 

History

History
1975 lines (1585 loc) · 81 KB

File metadata and controls

1975 lines (1585 loc) · 81 KB

Phaser WEBGL3D Renderer

WEBGL3D is an opt-in, minimal 3D rendering backend shipped alongside Phaser's classic 2D renderers. Games opt in with a single game-config flag and get access to a small but purposefully minimal 3D pipeline: perspective and orthographic cameras, a scene graph, mesh primitives, unlit and Lambertian-lit shaders with optional stylization controls (vertex snap, affine UVs, smooth/flat shading), ambient + directional + up to four point lights, distance fog, vertex colours, frustum culling and a WebGL2RenderingContext to build on.

This document describes the public API, the available knobs and the current limits of the pipeline. If you just want to get a cube on screen, jump to Quick start.

Status: experimental. The API is stable enough to build demos against, but ships behind the WEBGL3D_RENDERER build flag and is not selected by Phaser.AUTO. Expect the surface to grow (loaders, specular shading, model importers) before it stabilises.


Table of contents


Quick start

class ArcadeScene extends Phaser.Scene {
    preload () {
        this.load.gltf('lisa', 'LISA.gltf');
    }

    create () {
        const tex = this.textures.addCanvas('checker', makeCheckerboardCanvas());

        //  The plugin-managed main camera is ready to use from create().
        this.cameras3d.main.setPosition(0, 2, 6).lookAt(0, 0, 0);
        this.cameras3d.main.setFog(0x101030, 4, 18);

        //  One-call lighting presets cover the common cases.
        this.lights3d.preset('studio');

        const material = this.add3D.material('lit', {
            texture: tex,
            vertexSnap: 2,
            affineUV: true
        });

        //  this.add3D.* creates the mesh AND registers it with the scene's
        //  3D display list automatically - mirrors the 2D this.add.* API.
        this.cube = this.add3D.cube({ material, x: -1.5, y: 0, z: 0 });

        this.lisa = this.add3D.gltf('lisa', {
            x: 1.5,
            scale: 0.008,
            rotationX: -Math.PI,
            animation: 0,
            material: 'unlit'
        });
    }

    update (_time, delta) {
        this.cube.rotation.y += delta / 1000;
    }
}

new Phaser.Game({
    type: Phaser.WEBGL3D,
    width: 640,
    height: 360,
    backgroundColor: '#1b1b3a',
    scene: [ ArcadeScene ]
});

A runnable version of this demo lives at examples/vite-3d/; see its local README for the Vite workflow and the LISA glTF example.


Enabling the renderer

Set type: Phaser.WEBGL3D in the Game config. The renderer requires WebGL2; there is no fallback — if the device does not support WebGL2, Phaser throws at boot and you should trap the error and show a user-friendly message.

new Phaser.Game({
    type: Phaser.WEBGL3D,   // 9
    width: 640,
    height: 360,
    backgroundColor: '#1b1b3a',
    scene: [ MyScene ]
});

Notes:

  • Phaser.AUTO will never pick WEBGL3D. You have to opt in explicitly.
  • transparent, antialias, premultipliedAlpha, powerPreference, preserveDrawingBuffer, desynchronized, failIfMajorPerformanceCaveat and backgroundColor are honoured through the standard Phaser config path.
  • Running 2D content on a WEBGL3D game is not supported in this phase. The intended layout for a hybrid game is a 3D game for the scene, with 2D UI rendered in a separate overlay Scene on the classic WEBGL renderer.

Scene plugins

The WEBGL3D renderer comes with three Scene plugins that get installed into every Scene automatically when the renderer is built in:

Scene property Plugin Role
this.cameras3d Phaser.Cameras.ThreeD.CameraManager3D Owns the Scene's 3D cameras. Exposes main and factory methods.
this.add3D Phaser.GameObjects3D.GameObjectFactory3D Creates 3D game objects AND registers them with displayList3D.
this.lights3d Phaser.Lights3D.LightManager3D Owns the Scene's ambient + directional + point lights rig.

This mirrors Phaser's usual this.cameras / this.add / this.lights idioms from the 2D renderer.

API coverage in this guide

This page documents the public surface intended for game code:

  • this.cameras3d.main, camera creation, lookAt, fog and frustum culling.
  • this.add3D.material, cube, plane, gltf, object3D, mesh, billboard, blobShadow, existing and remove.
  • Object3D, Mesh3D, Cube, Plane, Billboard3D, BlobShadow3D and transform methods.
  • Friendly material presets (lit, unlit) and low-level material types.
  • this.lights3d.preset, ambient, directional and point lights.
  • this.load.gltf, this.cache.gltf, glTF factory options, animations and skinning.
  • Renderer stats, texture upload behavior, transparency, alpha test, vertex colors and the built-in shader set.

Internal classes such as MaterialManager, shader modules and the GL upload helpers are described only where they affect user-facing behavior.

this.cameras3d

//  The default main camera is auto-created at Scene boot time, sized to the
//  canvas aspect ratio and auto-resized by the Scale Manager.
this.cameras3d.main.setPosition(0, 2, 6).lookAt(0, 0, 0);

//  Extra cameras:
const ortho = this.cameras3d.addOrthographic({
    viewWidth: 12, viewHeight: 7, near: 0.1, far: 200
});
const perspective = this.cameras3d.addPerspective({ fov: 75 });

this.cameras3d.setMain(perspective);

this.add3D

this.add3D.material('lit', { texture });                 // friendly material helper
this.add3D.cube({ material: 'lit', color, x, y, z });     // Phaser.GameObjects3D.Cube
this.add3D.plane({ texture, scaleX: 10, scaleZ: 10 });    // Phaser.GameObjects3D.Plane
this.add3D.billboard({ texture, x, y, z });               // camera-facing plane
this.add3D.blobShadow(target, { y: 0, radius: 0.6 });      // cheap contact shadow
this.add3D.gltf('hero', { scale: 0.01, animation: 0 });   // imported glTF model
this.add3D.raycastFromPointer(pointer);                   // coarse picking helper

//  Low-level forms remain available:
this.add3D.cube(material);
this.add3D.plane(material);
this.add3D.object3D();                  // empty pivot (parenting)
this.add3D.mesh(geometry, material);    // bare Phaser.GameObjects3D.Mesh3D
this.add3D.existing(myMesh);            // add an externally-built node
this.add3D.remove(myMesh);              // unregister (doesn't destroy)

Nodes added via add3D are automatically part of scene.displayList3D, which is the array the renderer walks each frame. Child nodes do NOT need to be added explicitly — only the root does; the renderer recurses into each root's subtree.

Friendly primitive configs

cube and plane both accept either a real Material instance or a config object. The config object can include transform fields (x, y, z, scale, scaleX, scaleY, scaleZ, rotationX, rotationY, rotationZ) plus the same material fields accepted by this.add3D.material.

const crate = this.add3D.cube({
    material: 'lit',
    texture: this.textures.get('crate'),
    x: 2,
    y: 0,
    z: -1,
    scale: 0.8,
    vertexSnap: 2,
    affineUV: true
});

const floor = this.add3D.plane({
    material: 'lit',
    texture: this.textures.get('floor'),
    y: -1,
    scaleX: 20,
    scaleZ: 20
});

Blob shadows

blobShadow(target, config) creates a transparent circular plane that follows the target on X/Z and stays at a fixed Y height. It is a cheap contact cue, not a shadow map.

const shadow = this.add3D.blobShadow(this.hero, {
    y: -0.98,
    radius: 0.55,
    alpha: 0.35
});

Useful config:

  • y — fixed world-space height for the shadow plane.
  • radius — shadow radius in world units.
  • alpha — tint alpha when using the default generated texture.
  • texture — optional custom soft-circle texture.
  • followY — when true, follows target Y instead of staying at fixed y.

Billboards

billboard(config) creates a vertical plane that faces the active 3D camera around the Y axis. Use it for pickups, labels, simple markers and diegetic UI.

const pickup = this.add3D.billboard({
    texture: this.textures.get('pickup'),
    x: 0,
    y: 1,
    z: 2,
    width: 0.5,
    height: 0.5,
    transparent: true,
    cullFace: 'none'
});

The first implementation is cylindrical: it rotates around Y and stays upright. That keeps labels readable and avoids roll/pitch surprises.

For legacy code that sets scene.camera3D / scene.displayList3D manually (pre-plugin style), both lookups still work: the renderer prefers the plugin when available and falls back to the raw properties.


Scene graph

Phaser.GameObjects3D.Object3D

Base class for any 3D node. Owns a transform (position, rotation, scale), optional parent / children arrays and a visible flag, but no geometry on its own. Use it as a pivot or grouping node.

const pivot = this.add3D.object3D();
for (let i = 0; i < 3; i++) {
    const c = new Phaser.GameObjects3D.Cube(this, material);
    c.setPosition(Math.cos(a) * r, 0, Math.sin(a) * r);
    pivot.add(c);       // attach as child; children.parent is updated
}
//  Rotating the pivot rotates all three cubes around the shared centre.
pivot.rotation.y += dt * 0.6;

Every node exposes:

  • add(child) / remove(child) — maintain parent links consistently.
  • getLocalMatrix() — rebuilt from position/rotation/scale (YXZ rotation).
  • getWorldMatrix() — walks the parent chain; this is what the renderer actually uses.
  • setPosition(x, y, z), setRotation(x, y, z), setScale(x, y?, z?).
  • lookAt(x, y, z, yawOnly?) or lookAt(target, yawOnly?) — rotates local +Z toward a point, vector-like object or another Object3D; pass true for yawOnly to keep characters upright.
//  Turn a guard toward the player without writing yaw math in game code.
guard.lookAt(player, true);

Phaser.GameObjects3D.Mesh3D

Drawable node — extends Object3D with CPU-side geometry, a material and a bounding sphere for culling.

const mesh = new Phaser.GameObjects3D.Mesh3D(scene, {
    positions: new Float32Array([...]),   // required, xyz floats
    uvs: new Float32Array([...]),         // optional, uv floats
    normals: new Float32Array([...]),     // optional, xyz floats
    colors: new Float32Array([...]),      // optional, rgb floats (0..1)
    indices: new Uint16Array([...]),      // optional
    boundingSphere: { center: [x,y,z], radius: r } // optional
}, material);

this.add3D.existing(mesh);

Setting mesh.visible = false prunes the whole subtree in one shot.

Destruction

mesh.destroy();           // releases GPU buffers + detaches from parent
pivot.children.length = 0;
pivot.destroy();

destroy() is cooperative: it does NOT recursively destroy children. Destroy them explicitly first if that's what you want.


Cameras

Phaser.Cameras.ThreeD.Camera3D

Base class. Owns position, target, up, a view matrix, a projection matrix (maintained by subclasses), a view-projection matrix and a Frustum. After any change, call update() — the renderer does this for you once per frame.

camera.setPosition(x, y, z);
camera.lookAt(x, y, z);
camera.setUp(x, y, z);
camera.fixed([ 0, 2, 6 ], [ 0, 1, 0 ]);
camera.follow(player, { offset: [ 0, 2, 6 ], lookAtOffset: [ 0, 1, 0 ] });
camera.orbit(player, { distance: 6, yaw: 0, pitch: 0.3 });
camera.firstPerson([ 0, 1.6, 0 ], yaw, pitch);
camera.clearControl();
camera.setFog(0x101030, 4, 18);   // or { [r,g,b], near, far }
camera.clearFog();
camera.frustumCulling = false;    // opt out of per-mesh culling
camera.update();

Camera control helpers

The low-level camera model is always position + target + up. For common game cameras, use the high-level helpers:

//  Fixed camera: one-off position and look-at target.
this.cameras3d.main.fixed([ 0, 2, 7 ], [ 0, 1, 0 ]);

//  Follow camera: recomputes from a moving Object3D each frame.
this.cameras3d.main.follow(this.player, {
    offset: [ 0, 2, 6 ],
    lookAtOffset: [ 0, 1, 0 ]
});

//  Orbit camera: useful for model viewers and inspection modes.
this.cameras3d.main.orbit(this.player, {
    distance: 6,
    yaw: 0,
    pitch: 0.3,
    lookAtOffset: [ 0, 1, 0 ]
});

//  First-person style transform from position + yaw/pitch.
this.cameras3d.main.firstPerson([ 0, 1.6, 0 ], yaw, pitch);

follow and orbit accept either an Object3D, a Vector3, an array [x, y, z], or any object with x, y, z. If the target is an Object3D, the camera reads its world matrix every frame, so parented / animated nodes still work.

These helpers are immediate: there is no smoothing, damping or collision yet. Call clearControl() to return to manual setPosition / lookAt control.

Phaser.Cameras.ThreeD.PerspectiveCamera

const cam = this.cameras3d.addPerspective({
    fov: 60,           // degrees, vertical FOV
    aspect: 16 / 9,
    near: 0.1,
    far: 1000,
    position: [0, 2, 6],
    target: [0, 0, 0]
});
cam.setAspect(w / h);  // used by the manager's auto-resize
cam.setFOV(75);

Phaser.Cameras.ThreeD.OrthographicCamera

Symmetric or asymmetric parallel projection. Ideal for isometric views, HUD-like 3D overlays or editor cameras.

const cam = this.cameras3d.addOrthographic({
    viewWidth: 12,     // world units horizontally
    viewHeight: 7,
    near: 0.1,
    far: 200
});

cam.setSize(16, 9);                          // symmetric
cam.setFrustum(-8, 8, -4.5, 4.5);            // asymmetric
cam.setClip(0.01, 500);
cam.setAspect(w / h);                        // keeps viewHeight

Fog

Every Camera3D owns a fog descriptor:

cam.fog = {
    enabled: false,
    color: [ r, g, b ],
    near: 1,
    far: 100
};

Call cam.setFog(color, near, far) to enable it. Materials can opt out individually via material.fog = false (useful for UI overlays or skyboxes). Fog is applied in screen space as a linear blend between near and far.


Meshes and primitives

Phaser.GameObjects3D.Cube

Unit cube centred at the origin, 1 unit per side. Per-face UVs so a single texture tiles identically on every face. Ships with a tight bounding sphere for culling.

const cube = this.add3D.cube(material);
cube.setScale(1.25);

Phaser.GameObjects3D.Plane

1x1 plane on the XZ plane, facing +Y. Useful as a floor, a ceiling or a billboard starting point.

const floor = this.add3D.plane(material);
floor.setScale(10, 1, 10);

Geometry for Cube and Plane is built once, cached at module scope and shared across instances. GPU buffers are uploaded lazily on first draw and reused across frames.

Phaser.GameObjects3D.Billboard3D

Billboard3D extends Plane and rotates around the Y axis so it faces the active camera. It keeps a vertical orientation, which is usually what you want for pickups, labels and 3D UI.

const label = this.add3D.billboard({
    texture: labelTexture,
    x: 0,
    y: 2,
    z: 0,
    width: 1,
    height: 0.35,
    transparent: true
});

Phaser.GameObjects3D.BlobShadow3D

BlobShadow3D extends Plane and follows a target automatically. It updates right before matrix calculation, so it tracks animated or parented targets without extra Scene update code.

this.add3D.blobShadow(this.player, {
    y: 0,
    radius: 0.6,
    alpha: 0.3
});

Materials and textures

Colour space

The WEBGL3D renderer ships two colour-space modes, controlled by the optional webgl3d.colorSpace field on the Game config:

new Phaser.Game({
    type: Phaser.WEBGL3D,
    webgl3d: {
        colorSpace: 'srgb'   // 'linear' (default) or 'srgb'
    },
    scene: [ MyScene ]
});
  • 'linear' (default): no colour-space conversion. User-supplied colours and textures are interpreted verbatim and written straight to the framebuffer. This preserves the historical look and is the safest mode while integrating WEBGL3D into an existing Phaser 2D pipeline.
  • 'srgb': every user-facing colour (Material#color, Material#emissive, light colours, fog colour, vertex colours) and every sampled texture (baseColor, emissiveTexture) is treated as sRGB. The shader converts to linear, performs lighting in linear space, sums emissive and fog, then encodes the final fragment back to sRGB.

The active mode is exposed at runtime as game.renderer.colorSpace.

Notes:

  • Switching to 'srgb' will visibly change scenes that rely on baked or eye- tweaked colours, especially saturated lit materials and emissive lobes. Re-tune the lighting rig instead of fighting the curve.
  • The conversion uses an approximation (pow(c, 2.2) / pow(c, 1/2.2)), matching what most viewers do at the diffuse-only end of the spectrum.

Ambient occlusion

lit_textured (and lit_textured:skinned) optionally read a baked ambient-occlusion map. The value comes from the texture's red channel (matching glTF's occlusionTexture convention) and multiplies the diffuse lobe (ambient + directional + point lights). Specular highlights and emissive contributions are not affected, so a glossy material can still pop in deep crevices.

const wallMat = this.add3D.material('lit', {
    texture: wallTex,
    normalTexture: wallNormalTex,
    occlusionTexture: wallAOTex,
    occlusionStrength: 0.85
});

Notes:

  • Only consumed by lit_textured. Unlit and colour-only flavours ignore the texture; the renderer skips the bind for those programs.
  • occlusionStrength is the blend factor: the shader does lighting *= mix(1.0, occ, strength), so 0 disables the map without having to clear it.
  • Sampled with the active TEXCOORD set (Material#texCoord, default 0). glTF assets whose occlusionTexture.texCoord differs from baseColorTexture.texCoord raise a downgrade note and fall back to the baseColor channel.
  • glTF's occlusionTexture.strength is forwarded verbatim into Material#occlusionStrength.

TEXCOORD_1 (second UV channel)

Every textured material exposes Material#texCoord. The default 0 samples Mesh3D#uvs (TEXCOORD_0); set it to 1 to sample Mesh3D#uvs2 (TEXCOORD_1). The four *_textured shader programs share the same u_useUV1 toggle so the same channel drives every map (baseColor, emissive, normal, occlusion).

const mat = this.add3D.material('lit', {
    texture: paintedTex,
    occlusionTexture: bakedAOTex,
    texCoord: 1                      // sample everything from TEXCOORD_1
});
mat.setTexCoord(0);                  // chainable; flips back to TEXCOORD_0

The renderer falls back to TEXCOORD_0 automatically when the mesh does not carry a second UV set, so a partial import never samples uninitialised data.

Notes:

  • Only TEXCOORD_0 and TEXCOORD_1 are loaded today. Higher channels (TEXCOORD_2+) are dropped at load time with a warning.
  • glTF imports read pbrMetallicRoughness.baseColorTexture.texCoord. If the value is 1, every map for that material switches to TEXCOORD_1. Divergent texCoord values on emissive, normal or occlusion are recorded in material.downgrade[] and the loader keeps the baseColor channel for every map.
  • Texture transforms (see below) are applied after the channel is chosen, so KHR_texture_transform and texCoord = 1 compose cleanly.
  • Cube and Plane primitives only carry TEXCOORD_0 today, so texCoord = 1 on a primitive falls back transparently.

Texture transforms

Every textured material (unlit_textured, lit_textured and their :skinned variants) exposes a per-material UV transform that is applied before each texture(...) sample. Use it to slide / tile / rotate a texture atlas at runtime without resampling the geometry, build conveyor / scrolling backgrounds, or animate detail layers.

const crateMat = this.add3D.material('lit', {
    texture: crateTex,
    normalTexture: crateNormalTex,
    occlusionTexture: crateAOTex,
    textureTransform: { offset: [ 0, 0 ], scale: [ 1, 1 ], rotation: 0 }
});

//  Slide the pattern over the crate over time
this.events.on('update', (_, delta) => {
    const t = crateMat.textureTransform;
    t.offset[0] = (t.offset[0] + delta / 5000) % 1;
    t.rotation += delta / 4000;
});

The transform composes as M = T(offset) * R(rotation) * S(scale), exactly matching the glTF KHR_texture_transform extension.

Notes:

  • The transform is global to the material: the same matrix is used for baseColor, emissive, normal and occlusion samples. This keeps the shader cost to a single mat3 upload per draw and is the right shape for the common "the maps share UVs" case.
  • Pass null (or omit textureTransform) for the identity transform; the shader still reads u_uvTransform but the renderer uploads the identity matrix so there is no math cost.
  • setTextureTransform({ offset, scale, rotation }) accepts a partial spec and fills in defaults (offset = [0,0], scale = [1,1], rotation = 0).
  • The fields you read back via material.textureTransform are mutable arrays, so t.offset[0] += dt * speed works in update(). The renderer rebuilds the matrix every draw, so you do not need to call a setter.
  • glTF imports read KHR_texture_transform off pbrMetallicRoughness.baseColorTexture and apply that transform to every map. If emissiveTexture, normalTexture or occlusionTexture declare their own (different) transform, the loader keeps the baseColor transform and surfaces the divergence as a downgrade note. The KHR_texture_transform.texCoord override is also reported and ignored — the channel comes from the textureInfo's texCoord (see TEXCOORD_1 (second UV channel)).
  • affineUV: true (vertex-snapped affine interpolation) is applied to the raw UVs before the transform is applied, so per-triangle perspective artefacts and the transform compose intuitively.

Specular highlights

lit_color and lit_textured (and their :skinned variants) optionally add a Blinn-Phong specular lobe on top of the diffuse Lambertian shading. The lobe is not PBR; it is the classic Phong-family highlight you can use to give materials a glossy metal / plastic feel without dragging in a full PBR pipeline.

const crateMat = this.add3D.material('lit', {
    texture: crateTex,
    specular: [ 0.6, 0.6, 0.7 ],
    shininess: 80
});

Notes:

  • specular defaults to [0, 0, 0], so unused materials pay nothing: the shader skips the lobe entirely when the colour is black.
  • shininess is the exponent. Try values around 8-16 for soft highlights, 32-64 for plastic, 128+ for sharp metal-like reflections.
  • The lobe is added per-light (directional + each enabled point light) using the halfway vector (H = normalize(L + V)), so a moving point light produces a moving highlight.
  • Specular is multiplied by the light colour, not the base colour, so a coloured specular term lets you tint the highlight independently from the diffuse.
  • glTF imports do not auto-fill specular: the metallic-roughness model is still ignored (the lobe is opt-in via Material#specular). For glTF assets that should look glossy, set specular after add3D.gltf(...) or via the material: callback option.

Normal maps

lit_textured (and lit_textured:skinned) support an optional tangent-space normal map. The shader builds the tangent frame from screen-space derivatives of world position and UV (the same getTangentFrame fallback three.js uses) so meshes do not need to ship explicit TANGENT attributes.

const wallMat = this.add3D.material('lit', {
    texture: wallTex,
    normalTexture: wallNormalTex,
    normalScale: 0.85
});

Notes:

  • Only consumed by lit_textured materials. Unlit and colour-only flavours ignore the texture.
  • normalScale scales the perturbed normal's xy components only; z stays at the value packed in the texture, so 0 keeps the geometry normal and larger values exaggerate the bumps.
  • Sampled with the active TEXCOORD set (Material#texCoord, default 0). glTF assets whose normalTexture.texCoord differs from baseColorTexture.texCoord raise a downgrade note and fall back to the baseColor channel.
  • The fallback derivative TBN is robust for typical lit surfaces. If your asset has highly distorted UVs and you need fidelity, consider baking lighting into the base texture instead.

Emissive

Every material flavour exposes an emissive term that is added to the final fragment after lighting and before fog. It does not depend on the lighting rig, which makes it ideal for screens, signs, eyes, magic glow and pickups.

const screen = this.add3D.material('lit', {
    texture: screenTex,
    emissive: [ 0.6, 0.85, 1.0 ],
    emissiveIntensity: 1.0,
    emissiveTexture: emissiveMaskTex   // optional
});

//  Pulse the glow without rebuilding the colour
this.events.on('update', (_, delta) => {
    screen.emissiveIntensity = 0.5 + 0.5 * Math.sin(time / 250);
});

Notes:

  • emissive defaults to [0, 0, 0] so unused materials pay zero cost.
  • emissiveIntensity scales the colour every frame and is the cheap way to pulse / dim a glow.
  • emissiveTexture is sampled with the active TEXCOORD set (Material#texCoord, default 0) and only applies to *_textured materials. The texture multiplies the emissive colour, so a black emissive defaults the map to black even if it is bound.
  • Fog is applied after emissive, so distant emissive surfaces still fade with the fog. Disable fog per material (fog: false) for skybox / UI glow that should ignore the haze.

Friendly material presets: lit and unlit

Most games should start with this.add3D.material(type, config) instead of constructing shader-specific materials directly.

'lit' means: pick a material that receives light from this.lights3d.

  • With a texture, it becomes the internal shader type lit_textured.
  • Without a texture, it becomes lit_color.
  • It uses the Scene's ambient light, directional light and up to four point lights.
  • It is the right choice for floors, walls, crates, props, architecture and anything that should react to lighting.
const crateMat = this.add3D.material('lit', {
    texture: this.textures.get('crate'),
    color: [ 1, 1, 1, 1 ],
    shading: 'smooth'
});

'unlit' means: draw the texture or colour as-is, ignoring the light rig.

  • With a texture, it becomes unlit_textured.
  • Without a texture, it becomes unlit_color.
  • It is the right choice for UI-like 3D elements, skyboxes, emissive props, billboards, debug geometry and characters whose lighting was already painted into the texture.
const characterMat = this.add3D.material('unlit', {
    texture: this.textures.get('character')
});

For glTF models, the same terms can be used with material:

this.hero = this.add3D.gltf('hero', {
    scale: 0.01,
    animation: 0,
    material: 'unlit'
});

That converts every generated glTF material to the unlit shader family while preserving texture, alpha test, culling and skinning state.

Use the low-level names (lit_textured, unlit_color, etc.) only when you need to target a specific shader variant directly.

Low-level Phaser.Renderer.WebGL3D.Material

The friendly API is this.add3D.material(type, config), where type can be 'lit', 'unlit', or one of the low-level shader names:

const litCrate = this.add3D.material('lit', {
    texture: this.textures.get('crate'),
    vertexSnap: 2,
    affineUV: true
});

const flatColour = this.add3D.material('unlit', {
    color: [ 1, 0.4, 0.4, 1 ]
});

For renderer-level work you can still instantiate Material directly. It binds a specific shader program to a small bag of parameters.

const mat = new Phaser.Renderer.WebGL3D.Material(type, config);
Parameter Default Description
type required 'unlit_color', 'unlit_textured', 'lit_color' or 'lit_textured'
color [1,1,1,1] Base colour (color variants) or tint (textured variants), linear RGBA
texture null Texture source (see below)
vertexSnap 0 Pixel grid for optional vertex quantisation (0 = off)
affineUV false Affine UV interpolation toggle
cullFace 'back' 'back', 'front' or 'none'
depthTest true Depth test (read against the depth buffer)
depthWrite !transparent Whether the material writes to the depth buffer. Independent of transparent.
transparent color[3] < 1 Route to the transparent pass. Auto-detected from alpha.
alphaTest 0 Alpha-test cutoff (0..1). Fragments with tex.a * tint.a < cutoff are discarded. Maps to glTF alphaMode: "MASK" / alphaCutoff. Only consumed by *_textured materials.
blendMode 'normal' 'normal', 'additive' or 'multiply'
fog true Apply the active camera's fog
vertexColors false Multiply by per-vertex colour (mesh must supply geometry.colors)
shading 'smooth' 'smooth' or 'flat'. Ignored by unlit_*. Toggleable at runtime.
emissive [0,0,0] RGB added to the final fragment after lighting and before fog
emissiveIntensity 1 Scalar multiplier applied to emissive every frame
emissiveTexture null Optional emissive map; multiplied onto emissive. *_textured only.
normalTexture null Optional tangent-space normal map. lit_textured (and :skinned) only.
normalScale 1 Multiplier applied to the perturbed normal's xy components
specular [0,0,0] Blinn-Phong specular highlight colour. lit_* only. Off when black.
shininess 32 Blinn-Phong specular exponent. Larger = sharper highlight
occlusionTexture null Optional baked AO map. lit_textured (and :skinned) only.
occlusionStrength 1 Blend factor for the AO map. 0 disables, 1 applies it fully.
textureTransform null Optional per-material UV transform { offset, scale, rotation }. *_textured only. Implements KHR_texture_transform.
texCoord 0 UV channel sampled by every map. 0 reads Mesh3D#uvs (TEXCOORD_0), 1 reads Mesh3D#uvs2 (TEXCOORD_1). *_textured only; falls back to 0 when the mesh lacks a second UV set.

Methods:

mat.setColor(r, g, b, a);            // auto-flags transparent when a<1
mat.setTexture(tex);
mat.setTransparent(true, 'additive');

The texture field accepts any of the following shapes — the renderer unwraps them transparently:

  • A Phaser.Textures.Texture (e.g. the return value of this.textures.addCanvas() or this.textures.get(key)).
  • A Phaser.Textures.TextureSource.
  • A Phaser.Textures.Frame.
  • A raw wrapper { webGLTexture, width, height, flipY, spectorMetadata } returned by any of the renderer's create*Texture helpers.

Uploading textures

The WebGL3DRenderer implements the subset of the 2D Texture Manager hooks required by Phaser's loader, so standard Phaser workflows work out of the box:

this.load.image('crate', 'assets/crate.png');
// ...
const tex = this.textures.get('crate');
const material = this.add3D.material('unlit', {
    texture: tex
});

Textures are uploaded with NEAREST filtering and CLAMP_TO_EDGE wrap by default, which is a predictable baseline for small textures and atlas-based assets. Mipmaps, trilinear filtering and anisotropic sampling are deliberately not exposed yet.


Stylization controls

Two optional shader features let a game choose a more quantised or affine look without changing the renderer itself:

Vertex snap (vertexSnap)

Quantises the vertex's clip-space XY to a pixel grid before final output, producing controlled vertex jitter at low output resolutions.

this.add3D.material('unlit', {
    vertexSnap: 2   // snap to a 2-pixel grid; set to 0 to disable
});

Larger values produce more aggressive jitter. 1–4 works well at 640×360.

Affine UVs (affineUV)

Defeats perspective-correct texture interpolation so UVs interpolate linearly across each triangle. Implemented via a cancellation trick (the shader passes uv * w and w as regular perspective-interpolated varyings and divides them in the fragment), because WebGL2's GLSL ES 3.00 reserves but does not implement the noperspective qualifier.

this.add3D.material('unlit', {
    affineUV: true
});

Works best with geometry subdivided enough that each triangle covers a modest screen area — large untessellated quads show the strongest warping, which you may or may not want.


Lights and shading

Lit materials (lit_color, lit_textured) read the Scene's this.lights3d plugin to compute Lambertian diffuse lighting per fragment. Unlit materials (unlit_color, unlit_textured) ignore the rig entirely — use them for UI, skyboxes, emissive accents and anything that should not receive light.

Lighting rig

The Phaser.Lights3D.LightManager3D plugin exposes four slots:

  • ambient — a single Phaser.Lights3D.AmbientLight3D. Adds a flat color * intensity term to every lit fragment, regardless of geometry orientation. Keeps shadowed faces from going solid black.
  • directional — a single Phaser.Lights3D.DirectionalLight3D. An infinitely distant source (like a sun) with a colour, intensity and normalised direction.
  • points[] — up to four Phaser.Lights3D.PointLight3D. Positional omnidirectional lights with a radius and a smooth falloff.
  • spots[] — up to four Phaser.Lights3D.SpotLight3D. Positional and directional lights with a soft cone (inner / outer half-angles) on top of the same range falloff. Use them for flashlights, lamp posts, signage spots and any "this thing emits in a direction" cue.

Both the ambient and directional slots are auto-created with sensible defaults (ambient (0.1, 0.1, 0.12), directional warm-white from above-front), so new scenes with lit materials look reasonable without any user configuration.

//  Named presets for common scenes.
this.lights3d.preset('studio'); // neutral model / character preview
this.lights3d.preset('moody');  // low-key warm scene lighting
this.lights3d.preset('none');   // ambient only, no directional / points

//  Ambient + directional: tweak via the plugin.
this.lights3d.setAmbient({ color: 0x1a1a2a, intensity: 1 });
this.lights3d.setDirectional({
    color: 0xfff1d0,
    intensity: 0.85,
    direction: [ -0.35, -1, -0.25 ]
});

//  Point lights: up to four; returns null once the slot limit is reached.
const torch = this.lights3d.addPoint({
    color: 0xff7a33,
    intensity: 2.4,
    position: [ 0, 1, 0 ],
    range: 5.5          // radius at which the light fades to zero
});

//  Move the light per frame; its position is read fresh on every draw.
torch.setPosition(Math.cos(t) * 2, 1, Math.sin(t) * 2);

//  Remove / turn off:
this.lights3d.remove(torch);
torch.enabled = false;            // alternative: keep the slot but skip it
this.lights3d.clearPoints();      // wipe all point lights in one shot

//  Spot lights: up to four. Same range falloff as point lights plus a
//  soft cone defined by inner / outer half-angles (radians).
const flashlight = this.lights3d.addSpot({
    color: 0xfff1c4,
    intensity: 2.4,
    position: [ 0, 1.6, 4 ],
    direction: [ 0, -0.1, -1 ],   // normalised on upload
    range: 14,
    innerCone: Math.PI / 12,      // 15° core (full strength inside)
    outerCone: Math.PI / 6        // 30° edge (zero contribution outside)
});

//  Aim the spot like a turret: any non-zero direction works.
flashlight.setDirection(Math.cos(t), -0.5, Math.sin(t));

//  Tighten / widen the cone at runtime.
flashlight.setCone(Math.PI / 16, Math.PI / 8);

this.lights3d.clearSpots();   // wipes every spot in one call

Preset details:

  • studio clears point and spot lights, sets a neutral white ambient light and a soft white directional light. Use it for model viewers, character previews and readable default scenes.
  • moody clears point and spot lights and applies a warmer, darker rig. Use it for scenes where fog and tinted light are part of the mood.
  • none clears point and spot lights, disables the directional light and leaves a white ambient-only setup. Use it when you want lit materials to stay readable but not visibly shaded, or as a starting point before fully custom lighting.

Lighting equation

For a fragment with world-space position P and normalised world-space normal N:

lit = ambient.color * ambient.intensity
    + dir.color * dir.intensity * max(dot(N, -dir.direction), 0)
    + sum over enabled point lights i of:
         points[i].color * points[i].intensity * max(dot(N, L_i), 0)
         * (1 - smoothstep(0, points[i].range, dist_i))
    + sum over enabled spot lights j of:
         spots[j].color * spots[j].intensity * max(dot(N, L_j), 0)
         * (1 - smoothstep(0, spots[j].range, dist_j))
         * smoothstep(cos(spots[j].outerCone), cos(spots[j].innerCone), dot(-L_j, spots[j].direction))

Where L_i / L_j is the unit vector from the fragment to the light position, and dist_i / dist_j is its distance. The final fragment colour is (base * lit) followed by fog mixing (if enabled for the material).

Attenuation uses 1 - smoothstep(0, range, dist) so the light fades to zero at exactly range and has no singularity at the centre. It is not physically correct (a real 1/d^2 falloff blows up at the origin and needs separate intensity tuning); smoothstep is easier to reason about for arcade-style scenes.

Spot lights add a cone gate on top of the same falloff: smoothstep(cos(outer), cos(inner), dot(-L, dir)). Cosines are precomputed on upload so the shader does not have to call cos() per fragment. With inner === outer the cone has a hard edge; otherwise the contribution rolls smoothly between the two angles.

Shading model

Lit materials accept a shading option:

  • 'smooth' (default) — GLSL's standard perspective-correct normal interpolation. Visually smooth across triangles, even on coarse primitives.
  • 'flat' — uses GLSL ES 3.00's flat qualifier so every fragment of a triangle receives the normal of the provoking vertex. This produces faceted shading without duplicating vertices.
const mat = new Phaser.Renderer.WebGL3D.Material('lit_color', {
    color: [ 0.8, 0.4, 0.9, 1 ],
    shading: 'flat'
});

//  Can be toggled at runtime; the change picks up on the next frame.
mat.shading = 'smooth';

Normals and non-uniform scaling

Lit shaders require vertex normals. Built-in Cube and Plane supply per-face constant normals automatically; for custom meshes, provide a geometry.normals Float32Array alongside positions (same layout, one (x, y, z) per vertex).

Because the renderer transforms normals by the inverse-transpose of the model matrix (u_normalMatrix), non-uniform scaling (think a 40×1×40 floor plane) still produces correct lighting directions. You do not need to renormalise normals in your mesh data when changing a mesh's scale at runtime.

Fallback (no this.lights3d)

If the plugin is absent (e.g. a user-built minimal Scene plugin list), lit materials fall back to a deterministic dark rig: black ambient, directional intensity 0, zero point lights. Geometry is still drawn, it just appears unlit. Moving back to an unlit material is the simplest fix.


Loading glTF models

The WEBGL3D build exposes a minimal glTF 2.0 importer at this.load.gltf(key, url), mirroring the ergonomics of the core 2D loaders. It accepts both binary .glb containers and text .gltf + sidecar files (external .bin and images are resolved automatically, relative to the .gltf URL).

class MyScene extends Phaser.Scene {
    preload () {
        this.load.gltf('hero', 'models/hero.glb');
    }

    create () {
        //  Instantiate the loaded asset. Returns a root Object3D registered
        //  with the scene's displayList3D, so it renders next frame.
        this.hero = this.add3D.gltf('hero', {
            x: 0,
            y: 0,
            z: 0,
            scale: 0.01,
            animation: 0,
            material: 'unlit'
        });
    }
}

Calling this.add3D.gltf(key, options) clones the imported hierarchy into live Object3D / Mesh3D nodes. Geometry arrays and Material instances are shared between invocations — spawning 100 instances of the same model does not duplicate CPU-side vertex data or GL programs. Only the transforms are per-instance.

Supported subset (Stacks 9 + 10)

Feature Supported
.glb (binary) Yes
.gltf + external .bin + images Yes
data: URI buffers / images Yes
Mesh primitives: POSITION, NORMAL, TEXCOORD_0, TEXCOORD_1, COLOR_0, indices Yes — TEXCOORD_1 is forwarded to Mesh3D#uvs2 and used when Material#texCoord = 1
Mesh primitives: TEXCOORD_2 and higher Dropped at load time with a warning
Mesh primitives: JOINTS_0, WEIGHTS_0 (linear-blend skinning) Yes — promotes the mesh to SkinnedMesh3D
Node hierarchy (TRS or 4×4 matrix) Yes — matrix is decomposed into TRS on CPU
Skins (inverseBindMatrices, joints[]) Yes — up to 64 joints per mesh
Animations (LINEAR, STEP samplers on TRS channels) Yes — evaluated by AnimationMixer3D
Animations (CUBICSPLINE) Downgraded to LINEAR with a warning
Animations (weights / morph targets) Dropped with a warning (Stack 11+)
PBR metallic-roughness (baseColorFactor / baseColorTexture) Yes — mapped to lit_color / lit_textured
alphaMode OPAQUE / BLEND Yes
alphaMode MASK (with alphaCutoff) Yes — routed through the opaque pass with shader-side discard. Defaults cutoff to 0.5 when absent.
doubleSided Yes — mapped to cullFace: 'none'
KHR_lights_punctual (directional, point, spot) Yes — forwarded to this.lights3d (spots use innerConeAngle / outerConeAngle)
KHR_materials_unlit Yes — routes the material through the unlit_* shader family
emissiveFactor / emissiveTexture Yes — fed to Material#emissive and emissiveTexture
normalTexture / normalScale Yes — fed to Material#normalTexture and normalScale (lit_textured only)
occlusionTexture / strength Yes — fed to Material#occlusionTexture and occlusionStrength (lit_textured only)
KHR_texture_transform (on baseColorTexture) Yes — forwarded to Material#textureTransform and applied globally to every map
KHR_texture_transform (per-map override) Adopted from baseColor; divergent transforms on emissive/normal/occlusion are recorded in downgrade[]
Uint32 indices Yes (WebGL2 core)
Cameras Not yet — keep using this.cameras3d
Draco / meshopt / KTX2 Not yet

Unsupported features encountered at load time are either fatal (the load errors out and this.data stays empty — happens for required extensions only) or collected into asset.warnings[] and surfaced to the console as a single console.info message keyed by the load key.

PBR downgrade

The lit_* shaders are Lambertian diffuse only. When a glTF material declares features the shader cannot represent, they are silently ignored and recorded in asset.materials[i].downgrade[]:

glTF input Converted to / note
pbrMetallicRoughness.baseColorFactor Material.color
pbrMetallicRoughness.baseColorTexture Material.texture (imports the image)
pbrMetallicRoughness.metallicFactor != 0 Ignored (no metallic lobe)
pbrMetallicRoughness.roughnessFactor != 1 Ignored (Lambertian)
pbrMetallicRoughness.metallicRoughnessTexture Ignored (no metallic-roughness lobe; bake the look into baseColorTexture or use Material#specular / Material#shininess for highlights)
*.textureInfo.texCoord (0 or 1) Material.texCoord (one channel per material; emissive / normal / occlusion divergence falls back to baseColor)
*.textureInfo.texCoord (>= 2) Falls back to 0
normalTexture / normalTexture.scale Material.normalTexture / Material.normalScale (lit_textured only)
occlusionTexture / occlusionTexture.strength Material.occlusionTexture / Material.occlusionStrength (lit_textured only)
emissiveFactor / emissiveTexture Material.emissive / Material.emissiveTexture
material.extensions.KHR_materials_unlit Material family routed through unlit_*
*.extensions.KHR_texture_transform (baseColor) Material.textureTransform (offset / scale / rotation), applied globally
*.extensions.KHR_texture_transform (per-map override) Ignored on emissive / normal / occlusion when it differs from baseColor
Other material.extensions.* Ignored

If your asset visibly needs specular highlights or normal maps, consider baking the look into baseColorTexture before export. The current material model is intentionally diffuse-only.

Factory options

this.add3D.gltf(key, {
    sceneIndex: 0,         // override asset.defaultSceneIndex
    includeLights: true,   // forward KHR_lights_punctual to this.lights3d
    vertexSnap: 2,         // apply vertex snap to every generated material
    affineUV: true,        // apply affine UVs to every generated material

    x: 0, y: 0, z: 0,      // root transform shorthand
    scale: 0.01,           // uniform scale, or scaleX / scaleY / scaleZ
    rotationX: -Math.PI,   // rotationX / rotationY / rotationZ in radians

    material: 'unlit',     // 'lit', 'unlit', a replacement Material, or a callback
    animation: 0,          // true / 0 = first clip, string = named clip, false = bind pose

    autoPlay: true,        // start the first animation clip on a loop
    autoUpdate: true       // hook the mixer into Scene UPDATE (default)
});

sceneIndex picks one of asset.scenes[] when the glTF declares more than one scene. includeLights defaults to true; set to false when you want to keep your hand-tuned Scene lighting untouched.

animation is the preferred shorthand for examples and game code. It accepts true or 0 (first clip), a numeric clip index, a clip name, or false for bind pose. autoPlay remains supported for backwards compatibility and accepts true (plays the first clip) or a clip name. Leave both falsy to trigger clips manually via root.mixer.play(name).

material: 'unlit' converts every generated glTF material to the unlit shader family while preserving texture, tint, alpha test, culling and the skinned flag. This is useful for diffuse-authored assets and character viewers. Passing 'lit' forces the lit family, a Material replaces every mesh material, and a callback receives (material, node) so you can return a customised material.

When the source asset declares KHR_materials_unlit the loader already routes the affected materials through the unlit_* family, so the override above is only needed for assets that ship as PBR but were authored with prelit textures (LISA is a typical case: the diffuse atlas already encodes lighting, but the asset does not declare the extension, so we still pass material: 'unlit').

Cache access

The parsed asset template lives in this.cache.gltf:

const asset = this.cache.gltf.get('hero');
console.log(asset.stats);          // { meshCount, primitiveCount, triangleCount, animationCount, skinCount }
console.log(asset.warnings);       // string[] of non-fatal notes
console.log(asset.lights.length);  // number of KHR_lights_punctual entries
console.log(asset.animations);     // shared GLTFAnimationClip[] templates
console.log(asset.skins);          // shared GLTFSkinTemplate[] (joints[], inverseBindMatrices)

Finding nodes and meshes

Every this.add3D.gltf(...) instance exposes helper methods for finding nodes inside the cloned hierarchy:

const hero = this.add3D.gltf('hero');

const hand = hero.findNode('RightHand');
const weaponSocket = hero.findNode((node) => node.name === 'WeaponSocket');
const firstSkinnedMesh = hero.findMesh((node) => node.type === 'SkinnedMesh3D');
const allMeshes = hero.findMeshes(() => true);
const allNamedSockets = hero.findNodes((node) => node.name.indexOf('Socket') !== -1);

Available helpers:

  • findNode(nameOrPredicate) — first matching node.
  • findNodes(nameOrPredicate) — all matching nodes.
  • findMesh(nameOrPredicate) — first matching Mesh3D / SkinnedMesh3D.
  • findMeshes(nameOrPredicate) — all matching meshes.

The helpers search the live instance, not the shared cache template. You can attach props to returned nodes, read world matrices, or customise individual mesh materials.

Large meshes: Uint32 indices

WebGL2 natively supports 32-bit index buffers, and the loader passes Uint32Array indices straight through when a glTF accessor uses UNSIGNED_INT. That lets you import a single mesh with more than 65535 vertices without splitting it. The lower-range types (UNSIGNED_BYTE → promoted to UNSIGNED_SHORT) and UNSIGNED_SHORT keep working as before.


Animations and skinning

glTF animations and skeletal meshes are parsed by the loader and wired up automatically by this.add3D.gltf(...). No extra configuration is required to import a rigged, animated character: the factory picks the right Mesh3D vs SkinnedMesh3D subclass per primitive and attaches a per-instance animation mixer.

Data model

Each import contributes three new objects:

  • asset.animations[] — GLTFAnimationClip templates, shared by every instance of the asset. Each clip carries name, duration (seconds), a list of samplers (input, output, interpolation) and a list of channels (samplerIndex, nodeIndex, path). Channels on unsupported paths (weights) are dropped during parsing and recorded in asset.warnings[].
  • asset.skins[] — GLTFSkinTemplate records with inverseBindMatrices (Float32Array of 16×jointCount floats, column- major) and joints[] (glTF node indices). Shared by every instance.
  • root.mixer / root.animations — allocated per instance by the factory. The mixer owns play / stop / crossFade / update and hooks into the Scene UPDATE event when autoUpdate !== false.

Skinned primitives receive an additional pair of vertex attributes at parse time: jointIndices (Uint16Array, four joint indices per vertex, shader location 4) and jointWeights (Float32Array, four normalised weights, shader location 5). They are uploaded as dedicated VBOs the first time the mesh reaches the renderer.

AnimationMixer3D

const hero = this.add3D.gltf('hero');

//  Start a clip by name:
hero.mixer.play('Idle', { loop: true });

//  Cross-fade to a new clip over 0.25 seconds:
hero.mixer.crossFade('Run', 0.25, { speed: 1.2 });

//  Stop everything:
hero.mixer.stopAll();

//  Peek at what's currently dominant (highest blend weight):
const active = hero.mixer.getCurrentAction();
console.log(active.clip.name, active.time.toFixed(2));

Options accepted by play() and forwarded through crossFade():

Option Default Meaning
loop true When false, the action stops at the last keyframe.
speed 1 Playback speed multiplier.
weight 1 Blend weight (combined with concurrent actions).
fadeIn 0 When > 0, the action's weight ramps 0 → weight.
time 0 Initial time offset in seconds.

Multiple actions can run simultaneously with independent weights; for rotation channels the mixer uses spherical interpolation, for translation / scale it uses a weighted sum.

The mixer converts sampled quaternion rotations into Euler YXZ (the format Object3D.rotation stores) every frame. Gimbal-lock is handled by Phaser's standard quatToEulerYXZ helper; in practice it's only visible when two channels combine a near-vertical tilt with a large twist — the default skeletons produced by Blender / glTF exporters stay well away from that regime.

SkinnedMesh3D

SkinnedMesh3D is a subclass of Mesh3D that keeps:

  • joints — a reference list of Object3D nodes in the instance hierarchy (up to SkinnedMesh3D.MAX_JOINTS === 64).
  • inverseBindMatrices — shared with the source asset. Never mutate.
  • jointMatrices — a 64×16 float buffer updated once per frame from joint[j].getWorldMatrix() * inverseBindMatrices[j], then uploaded verbatim as u_jointMatrix[64].

The renderer chooses *_skinned shader variants (unlit_color_skinned, unlit_textured_skinned, lit_color_skinned, lit_textured_skinned) when a material has skinned: true and the mesh actually ships jointIndices / jointWeights. A skinned material on a static mesh falls back to the static program, avoiding out-of-bounds reads on u_jointMatrix[].

Skinned meshes disable frustum culling by default (boundingSphere = null): the bind-pose bound is meaningless once the joints have moved, and a reliable dynamic bound is part of the roadmap. If your animation stays inside a fixed envelope you can assign a manual bound:

mesh.boundingSphere = { center: [ 0, 0, 0 ], radius: 2.5 };

For picking, SkinnedMesh3D keeps the original bind-pose sphere as pickSphere. It is deliberately coarse but good enough for character click selection. Assign boundingSphere or pickSphere manually when you need a tighter clickable envelope.

Example

preload () {
    this.load.gltf('character', 'assets/character.glb');
}

create () {
    const hero = this.add3D.gltf('character', { autoPlay: 'Idle' });
    hero.setPosition(0, 0, 0);

    this.input.keyboard.on('keydown-SPACE', () => {
        hero.mixer.crossFade('Jump', 0.15, { loop: false });
    });
}

Scaling considerations

  • Each mesh uploads one mat4[64] uniform = 4 KiB per draw. Batches of identical skinned meshes still pay per-draw uploads (combining skinning with GPU instancing is on the backlog).
  • Object3D.getWorldMatrix() walks the parent chain on every access. Deep skeletons (> 30 bones with many levels) will spend measurable time there; a dirty-flag cache is on the roadmap.

GPU instancing

When you need to draw thousands of copies of the same geometry — voxels, foliage, particles-as-cubes, swarm enemies, a procedural grid — wrapping each one in a separate Mesh3D hits the CPU draw-call ceiling fast (somewhere around 3–5 k cubes at 60 fps on a modern laptop). The InstancedMesh3D class collapses the whole batch into one gl.drawElementsInstanced call. The CPU cost stops scaling with the instance count and the GPU happily chews through 50 k–100 k+ cubes before fps suffers.

//  Friendliest path: a unit cube backed by GPU instancing, 50 000 max,
//  with a per-instance RGB tint buffer.
const swarm = this.add3D.instancedCube({
    material: 'lit',
    color: [ 0.85, 0.85, 0.95, 1 ],
    maxInstances: 50000,
    useColors: true,
    count: 0     //  start empty; raise as you spawn
});

//  Spawn 1 000 cubes the first time we want them visible.
for (let i = 0; i < 1000; i++)
{
    const x = (Math.random() - 0.5) * 20;
    const y = 0.5 + Math.random() * 4;
    const z = (Math.random() - 0.5) * 20;
    swarm.setPositionScaleAt(i, x, y, z, 0.4);
    swarm.setColorAt(i, Math.random(), Math.random(), Math.random());
}
swarm.count = 1000;
swarm.commitInstances();   //  flag the matrix / colour buffers dirty

//  Plane / arbitrary geometry helpers also exist.
const grass = this.add3D.instancedPlane({ texture: 'blade', maxInstances: 5000, useColors: true });
const fish  = this.add3D.instancedMesh(customGeometry, customMaterial, { maxInstances: 200 });

Per-instance API:

Method Purpose
setMatrixAt(i, mat) Writes one column-major mat4 (Float32Array of 16 or Phaser.Math.Matrix4).
setPositionAt(i, x, y, z) Quick path — translation only.
setPositionScaleAt(i, x, y, z, scale) Translation + uniform scale.
setColorAt(i, r, g, b) Per-instance tint (multiplied onto the material's base colour or texture sample).
getMatrixAt(i, out?) Reads slot i back into a Matrix4.
commitInstances() Mark the buffers dirty so the next draw re-uploads them.

The buffers are sized once at construction (maxInstances) and grown is not supported on the fly — pick a sensible cap up front. The active draw count is mesh.count (mutable, must satisfy 0 <= count <= maxInstances).

Limitations to keep in mind:

  • Frustum culling is per-mesh, not per-instance. The whole batch is one drawable; tighten boundingSphere so the camera test passes when the spawn area is in view, and use multiple InstancedMesh3D chunks if you need spatial culling.
  • Picking still uses the bounding sphere; per-instance raycasts are not implemented yet.
  • Skinning is not combined with instancing today. The four instanced shader variants are non-skinned (unlit_color, unlit_textured, lit_color, lit_textured); attempting to flag a material skinned: true on an instanced mesh selects the skinned static shader instead.
  • Per-instance normals transform as u_normalMatrix * mat3(a_instanceMatrix) * a_normal, exact for rotations and uniform scales. Non-uniform scales on the instance matrix will skew normals slightly; bake those into the geometry if accuracy matters.

The two demos #/stress-test and #/instanced-cubes run the same physics sandbox so you can compare the order-of-magnitude difference side by side: meshesDrawn = N vs. meshesDrawn = 1.


Picking and raycasts

The WEBGL3D API includes a first-pass picking system for gameplay and editor interactions. It is intentionally coarse: it tests rays against world-space bounding spheres, not individual triangles. This makes it fast and predictable for selecting characters, crates, props and trigger volumes.

Camera rays

Use Camera3D#getRay(x, y, width, height, out?) when you need the raw world-space ray:

const ray = this.cameras3d.main.getRay(
    pointer.x,
    pointer.y,
    this.game.renderer.width,
    this.game.renderer.height
);

The returned Ray3D has:

ray.origin;                  // Phaser.Math.Vector3
ray.direction;               // Phaser.Math.Vector3, normalised
ray.at(distance);            // point along the ray
ray.intersectSphere(x, y, z, radius);

Scene picking

Most games should use the factory helper:

this.input.on('pointerdown', (pointer) => {
    const hits = this.add3D.raycastFromPointer(pointer);
    const hit = hits[0];

    if (hit) {
        console.log('Picked', hit.root, hit.object, hit.point);
    }
});

raycastFromPointer(pointer, options?) accepts:

  • camera — defaults to this.cameras3d.main.
  • roots — optional object or object array to test instead of the whole 3D display list.
  • ray — optional reusable Ray3D instance to avoid allocation.

For already-built rays, call:

const hits = this.add3D.raycast(ray, [ player, crate, door ]);

Hit records are sorted nearest-first and have this shape:

{
    object,     // Mesh3D or SkinnedMesh3D that was hit
    root,       // root passed into raycast / top-level display object
    distance,   // distance along the ray
    point,      // world-space hit point
    sphere      // world-space sphere used for the test
}

Current limitations:

  • Bounding-sphere only; no triangle-accurate raycast yet.
  • Skinned meshes use a bind-pose pickSphere unless you provide a better one.
  • No layer masks yet; pass an explicit roots list to scope a query.

Transparency and blend modes

The renderer runs two passes per scene:

  1. Opaque pass. All materials with transparent === false (the default). Sorted front-to-back to help early-Z discard overdraw. Depth write on, blending off.
  2. Transparent pass. All materials with transparent === true. Sorted back-to-front (painter's algorithm) by camera distance. Depth write off (depth test still active), blending on.

A material is considered transparent when either:

  • config.transparent === true, or
  • color[3] < 1 and config.transparent was not set.

depthWrite is independent from transparent. It defaults to !transparent, which preserves the classic "opaque writes depth, transparent does not" split, but you can override it per material:

//  Alpha-tested foliage card: transparent pass would skip depth write,
//  but we want the leaves to still occlude what's behind them.
this.add3D.material('lit', {
    texture: foliage,
    alphaTest: 0.5,
    transparent: false,
    depthWrite: true
});

//  Overlay decal that should never occlude the geometry below it.
this.add3D.material('unlit', {
    texture: decal,
    transparent: true,
    depthWrite: false
});

Available blend modes:

Mode GL state
'normal' blendFunc(SRC_ALPHA, ONE_MINUS_SRC_ALPHA) — standard alpha compositing
'additive' blendFunc(SRC_ALPHA, ONE) — good for sparks / energy effects
'multiply' blendFunc(DST_COLOR, ONE_MINUS_SRC_ALPHA) — shadow / tint decals
this.add3D.material('unlit', {
    color: [ 0.3, 0.9, 1.0, 0.45 ],
    blendMode: 'normal',
    cullFace: 'none'         // see both sides of transparent geometry
});

Alpha test (cut-out materials)

For foliage, chain-link fences, hair cards or any asset that has hard cut-outs inside a single texture, sorting as transparent is overkill: you just want pixels below a cutoff to vanish while the rest behaves like a regular opaque surface. That's what material.alphaTest does.

When set to a value in (0, 1], the textured fragment shaders discard any fragment whose sampled alpha (post-tint) is below the cutoff. The mesh stays in the opaque pass - depth write on, no back-to-front sorting, no blend state changes - so it plays nicely with fog, other alpha-tested foliage and back-face culling.

this.add3D.material('lit', {
    texture: foliageAtlas,
    alphaTest: 0.5,
    cullFace: 'none'         // typical for foliage cards
});

glTF assets with alphaMode: "MASK" map to this path automatically: alphaCutoff is copied into material.alphaTest and transparent stays false. Only *_textured materials consume the flag; colour-only materials have no texture to sample so the cutoff is ignored (and the loader records a downgrade note if it sees MASK on a colour-only material).


Vertex colours

Set material.vertexColors = true and provide geometry.colors — a tightly packed Float32Array of RGB floats, one per vertex:

const colors = new Float32Array(vertexCount * 3);
for (let v = 0; v < vertexCount; v++) {
    colors[v*3 + 0] = r;
    colors[v*3 + 1] = g;
    colors[v*3 + 2] = b;
}

const geometry = { positions, uvs, indices, colors };
const material = new Phaser.Renderer.WebGL3D.Material('unlit_color', {
    vertexColors: true
});

Meshes without a colors buffer get a constant white default, so the same shader path is used regardless of per-material opt-in. Handy for baked ambient occlusion, level-art gradients and cheap "lighting".


Frustum culling and stats

Every Camera3D maintains a Frustum derived from its view-projection matrix. Before submitting a mesh, the renderer tests its world-space bounding sphere against the frustum. Meshes fully outside are skipped.

  • Cube and Plane ship with tight bounding spheres.
  • For raw Mesh3D instances, provide geometry.boundingSphere explicitly, or set camera.frustumCulling = false to bypass the test for that camera.
  • The bounding sphere radius is scaled by the mesh's world-space scale each frame; rotations don't matter.

The renderer exposes a per-frame stats object (reset in preRender):

renderer.stats = {
    drawCalls: 0,
    meshesDrawn: 0,
    meshesCulled: 0
};

Read it in a Scene update() to drive debug HUDs.


Built-in shaders

All four shaders target GLSL ES 3.00 (#version 300 es). They are in src/renderer/webgl3d/shaders/ if you want to read the source.

unlit_color

Solid colour. No texture, no lighting. Cheap enough to use for thousands of placeholder meshes.

Uniform Type Notes
u_model mat4 Per-mesh world matrix
u_viewProjection mat4 Shared per-camera
u_color vec4 Flat colour, RGBA
u_viewport vec2 Width / height in device pixels
u_snap float Pixel grid for vertex snap (0 = off)
u_useVColor float Multiply by a_color when > 0.5
u_fogEnabled float Apply fog when > 0.5
u_fogColor vec3 Fog colour (RGB)
u_fogNear float Fog start
u_fogFar float Fog full strength

Vertex attributes: a_position (vec3, location 0), a_color (vec3, location 2).

unlit_textured

Samples a texture and multiplies by a tint. Supports the same stylization controls plus u_affine.

Uniform Type Notes
u_model mat4 Per-mesh world matrix
u_viewProjection mat4 Shared per-camera
u_tint vec4 RGBA multiplier
u_texture sampler2D Texture unit 0
u_viewport vec2 Width / height
u_snap float Pixel grid for vertex snap
u_affine float 0 or 1: affine UV interpolation toggle
u_useVColor float Multiply by a_color when > 0.5
u_fogEnabled float Apply fog when > 0.5
u_fogColor vec3 Fog colour (RGB)
u_fogNear float Fog start
u_fogFar float Fog full strength

Vertex attributes: a_position (vec3, loc 0), a_uv (vec2, loc 1), a_color (vec3, loc 2).

lit_color

Solid colour + Lambertian diffuse lighting. Same cost envelope as unlit_color with lighting math added. All the fog / vertex-snap / vertex colour knobs are still honoured.

Uniform Type Notes
u_model mat4 Per-mesh world matrix
u_viewProjection mat4 Shared per-camera
u_normalMatrix mat3 Inverse-transpose of mat3(u_model)
u_color vec4 Flat base colour, RGBA
u_viewport vec2 Width / height in device pixels
u_snap float Pixel grid for vertex snap (0 = off)
u_shading float 0 = smooth, 1 = flat
u_useVColor float Multiply by a_color when > 0.5
u_ambientColor vec3 Ambient light RGB × intensity
u_dirColor vec3 Directional light RGB
u_dirDir vec3 Normalised directional light direction
u_dirIntensity float Directional light intensity
u_pointCount int Active point lights (0..4)
u_pointPos[4] vec3[4] Point-light world positions
u_pointColor[4] vec3[4] Point-light RGB colours
u_pointIntensity[4] float[4] Point-light intensities
u_pointRange[4] float[4] Point-light falloff radii
u_fogEnabled float Apply fog when > 0.5
u_fogColor vec3 Fog colour
u_fogNear / u_fogFar float Fog range

Vertex attributes: a_position (vec3, loc 0), a_color (vec3, loc 2), a_normal (vec3, loc 3).

lit_textured

Textured Lambertian diffuse shader. Merges every feature of unlit_textured (tint, texture sample, affineUV) with the lighting rig from lit_color.

Uniform Type Notes
u_model mat4 Per-mesh world matrix
u_viewProjection mat4 Shared per-camera
u_normalMatrix mat3 Inverse-transpose of mat3(u_model)
u_tint vec4 RGBA multiplier
u_texture sampler2D Texture unit 0
u_viewport vec2 Width / height
u_snap float Pixel grid for vertex snap
u_affine float 0 or 1: affine UV interpolation toggle
u_shading float 0 = smooth, 1 = flat
u_useVColor float Multiply by a_color when > 0.5
u_ambientColor vec3 Ambient light RGB × intensity
u_dirColor vec3 Directional light RGB
u_dirDir vec3 Normalised directional light direction
u_dirIntensity float Directional light intensity
u_pointCount int Active point lights (0..4)
u_pointPos[4] vec3[4] Point-light world positions
u_pointColor[4] vec3[4] Point-light RGB colours
u_pointIntensity[4] float[4] Point-light intensities
u_pointRange[4] float[4] Point-light falloff radii
u_fogEnabled float Apply fog when > 0.5
u_fogColor vec3 Fog colour
u_fogNear / u_fogFar float Fog range

Vertex attributes: a_position (vec3, loc 0), a_uv (vec2, loc 1), a_color (vec3, loc 2), a_normal (vec3, loc 3).


Renderer reference

Phaser.Renderer.WebGL3D.WebGL3DRenderer

Instantiated automatically by Phaser.Game when type: Phaser.WEBGL3D. You should rarely have to touch it directly, but the public surface is stable enough to rely on:

Field / Method Purpose
gl: WebGL2RenderingContext The active GL2 context
width, height Current drawing-buffer size
materialManager Owns compiled programs
stats Per-frame {drawCalls, meshesDrawn, meshesCulled}
preRender() / render() / postRender() Called by Phaser.Game#step
resize(w, h) Adjusts viewport (also hooked to Scale Manager)
createTextureFromSource(source, w, h, scale, clamp, flipY) Uploads a DOM element
createCanvasTexture(canvas, noRepeat, flipY) Uploads a canvas
createVideoTexture(video, noRepeat, flipY) Uploads a video
createUint8ArrayTexture(data, w, h, pma, flipY) Uploads raw RGBA pixels
releaseMeshGPU(mesh) Frees VAO/VBO/IBO for a mesh

Phaser.Renderer.WebGL3D.MaterialManager

Compiles and caches the shaders on renderer boot and exposes getProgram(type) for the inner render loop. You should not need to talk to it directly unless you're adding new built-in material types.

Phaser.Cameras.ThreeD.Frustum

Six-plane view frustum extracted via the Gribb-Hartmann method. You rarely instantiate one directly — each Camera3D owns its own and refreshes it on update(). Exposes intersectsSphere(cx, cy, cz, radius) if you want to reuse the same test from your own code.


Build flags

The 3D renderer is gated by the WEBGL3D_RENDERER webpack DefinePlugin flag (in config/webpack.config.js, config/webpack-nospector.config.js and config/webpack.dist.config.js). When the flag is true:

  • Phaser.Renderer.WebGL3D is included.
  • Phaser.Cameras.ThreeD is included (base camera, perspective, orthographic, frustum, manager plugin).
  • Phaser.GameObjects3D is included (Object3D, Mesh3D, Cube, Plane, factory plugin).
  • Phaser.Lights3D is included (Light3D, AmbientLight3D, DirectionalLight3D, PointLight3D, LightManager3D).
  • Phaser.Loader3D is included (GLTFParser, GLTFAsset, GLTFFile). this.load.gltf and this.cache.gltf become available.
  • Phaser.WEBGL3D is a valid renderer type.
  • this.cameras3d, this.add3D and this.lights3d are auto-injected into every Scene.

When the flag is false, the 3D code is tree-shaken out of the bundle and requesting type: Phaser.WEBGL3D throws at boot. This keeps 2D-only builds free of WebGL2 / 3D baggage.


What ships today and roadmap

What works today:

  • WebGL2 context setup, resize, clear, boot.
  • Perspective and orthographic cameras with FOV/aspect/near/far or symmetric/asymmetric frustum.
  • Scene plugins: this.cameras3d, this.add3D and this.lights3d with auto-managed displayList3D.
  • Object3D scene graph with parent / children and world matrices.
  • Cube and Plane primitives with shared geometry, bounding spheres and per-face normals.
  • Unlit solid colour / textured materials and lit solid colour / textured materials with Lambertian diffuse shading.
  • Lighting rig: ambient + directional + up to four positional point lights, all exposed via this.lights3d.
  • Smooth and flat shading per material, toggleable at runtime.
  • Per-vertex colour support on every shader.
  • Vertex snapping and affine UV controls for optional stylized output.
  • Distance fog on the camera, per-material opt-out.
  • Transparent pass with blend modes (normal, additive, multiply), depth-write split and back-to-front sorting.
  • Frustum culling per camera with bounding-sphere rejection.
  • Seamless integration with Phaser's Texture Manager and Loader.
  • glTF 2.0 loader (.glb + .gltf + sidecar) with this.load.gltf / this.add3D.gltf(key, options), PBR downgrade, node hierarchy and KHR_lights_punctual import.
  • 32-bit index buffers (Uint32) for meshes with more than 65535 vertices.
  • Per-frame render stats for HUDs / debugging.

Roadmap (on the backlog, not blocked by the current architecture):

  • Specular / Phong highlights: Blinn-Phong specular lobe is on the short-term list; Lambertian is all that ships today.
  • Shadow maps (and screen-space ambient occlusion) — vertex colour is the only AO channel right now.
  • Area lights: ambient, directional, point and spot lights all ship today (see Phaser.Lights3D.SpotLight3D). Area / disc / line lights are still backlog.
  • Morph targets / blendshapes: dropped with a warning at load time. The mixer already evaluates path === 'weights' channels, so enabling them is a shader + geometry attribute addition.
  • CUBICSPLINE interpolation: currently downgraded to LINEAR with a warning — proper Hermite evaluation is on the backlog.
  • IK, retargeting, root-motion extraction: the animation mixer is designed so these slot on top without changing the format.
  • More than 64 joints per skinned mesh: the current limit is a plain uniform array. Moving to a Uniform Buffer Object / texture will lift it (WebGL2 native).
  • Material batching / state sorting: GPU instancing now ships (InstancedMesh3D — see the "GPU instancing" section). The render loop still walks the display list in insertion order, so further wins from sorting opaque draws by (program, texture, material) remain on the backlog.
  • Skinning + instancing in the same shader: instancing today only supports the static lit_* / unlit_* shader variants. Combining it with *_skinned is a feature on the roadmap.
  • Snapshot / render-to-texture: game.renderer.snapshot() is not implemented on the 3D path yet.
  • glTF extras — normal / occlusion / emissive / metallic-roughness maps, KHR_materials_unlit, Draco / meshopt compression, KTX2, scene cameras. All collected into asset.warnings[] today so artists can see what fell off. (alphaMode: MASK is now honoured natively via shader-side alpha test, see Transparency and blend modes.)
  • Sprite billboards / particle system in 3D: planned, not shipped.
  • Mixing 2D and 3D in the same game: not supported in this phase. Use a separate 2D overlay Scene running on the classic WEBGL renderer if you need HUD/UI.

See examples/vite-3d/ for a minimal end-to-end integration.