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_RENDERERbuild flag and is not selected byPhaser.AUTO. Expect the surface to grow (loaders, specular shading, model importers) before it stabilises.
- Quick start
- Enabling the renderer
- Scene plugins (cameras3d, add3D, lights3d)
- Scene graph (Object3D, parenting)
- Cameras (perspective, orthographic, fog)
- Meshes and primitives
- Materials and textures
- Stylization controls
- Lights and shading
- Loading glTF models
- Animations and skinning
- Picking and raycasts
- Transparency and blend modes
- Vertex colours
- Frustum culling and stats
- Built-in shaders
- Renderer reference
- Build flags
- What ships today and roadmap
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.
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.AUTOwill never pickWEBGL3D. You have to opt in explicitly.transparent,antialias,premultipliedAlpha,powerPreference,preserveDrawingBuffer,desynchronized,failIfMajorPerformanceCaveatandbackgroundColorare honoured through the standard Phaser config path.- Running 2D content on a
WEBGL3Dgame 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 classicWEBGLrenderer.
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.
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,existingandremove.Object3D,Mesh3D,Cube,Plane,Billboard3D,BlobShadow3Dand 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.
// 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.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.
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
});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 fixedy.
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.
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?)orlookAt(target, yawOnly?)— rotates local +Z toward a point, vector-like object or anotherObject3D; passtrueforyawOnlyto keep characters upright.
// Turn a guard toward the player without writing yaw math in game code.
guard.lookAt(player, true);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.
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.
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();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.
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);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 viewHeightEvery 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.
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);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.
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
});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
});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.
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. occlusionStrengthis the blend factor: the shader doeslighting *= mix(1.0, occ, strength), so0disables the map without having to clear it.- Sampled with the active TEXCOORD set (
Material#texCoord, default0). glTF assets whoseocclusionTexture.texCoorddiffers frombaseColorTexture.texCoordraise adowngradenote and fall back to the baseColor channel. - glTF's
occlusionTexture.strengthis forwarded verbatim intoMaterial#occlusionStrength.
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_0The 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_0andTEXCOORD_1are loaded today. Higher channels (TEXCOORD_2+) are dropped at load time with a warning. - glTF imports read
pbrMetallicRoughness.baseColorTexture.texCoord. If the value is1, every map for that material switches to TEXCOORD_1. DivergenttexCoordvalues onemissive,normalorocclusionare recorded inmaterial.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_transformandtexCoord = 1compose cleanly. CubeandPlaneprimitives only carry TEXCOORD_0 today, sotexCoord = 1on a primitive falls back transparently.
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,normalandocclusionsamples. This keeps the shader cost to a singlemat3upload per draw and is the right shape for the common "the maps share UVs" case. - Pass
null(or omittextureTransform) for the identity transform; the shader still readsu_uvTransformbut 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.textureTransformare mutable arrays, sot.offset[0] += dt * speedworks inupdate(). The renderer rebuilds the matrix every draw, so you do not need to call a setter. - glTF imports read
KHR_texture_transformoffpbrMetallicRoughness.baseColorTextureand apply that transform to every map. IfemissiveTexture,normalTextureorocclusionTexturedeclare their own (different) transform, the loader keeps the baseColor transform and surfaces the divergence as adowngradenote. TheKHR_texture_transform.texCoordoverride is also reported and ignored — the channel comes from the textureInfo'stexCoord(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.
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:
speculardefaults to[0, 0, 0], so unused materials pay nothing: the shader skips the lobe entirely when the colour is black.shininessis the exponent. Try values around8-16for soft highlights,32-64for 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, setspecularafteradd3D.gltf(...)or via thematerial:callback option.
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_texturedmaterials. Unlit and colour-only flavours ignore the texture. normalScalescales the perturbed normal'sxycomponents only;zstays at the value packed in the texture, so0keeps the geometry normal and larger values exaggerate the bumps.- Sampled with the active TEXCOORD set (
Material#texCoord, default0). glTF assets whosenormalTexture.texCoorddiffers frombaseColorTexture.texCoordraise adowngradenote 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.
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:
emissivedefaults to[0, 0, 0]so unused materials pay zero cost.emissiveIntensityscales the colour every frame and is the cheap way to pulse / dim a glow.emissiveTextureis sampled with the active TEXCOORD set (Material#texCoord, default0) and only applies to*_texturedmaterials. The texture multiplies theemissivecolour, 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.
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 typelit_textured. - Without a
texture, it becomeslit_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 becomesunlit_textured. - Without a
texture, it becomesunlit_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.
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 ofthis.textures.addCanvas()orthis.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'screate*Texturehelpers.
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.
Two optional shader features let a game choose a more quantised or affine look without changing the renderer itself:
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.
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.
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.
The Phaser.Lights3D.LightManager3D plugin exposes four slots:
ambient— a singlePhaser.Lights3D.AmbientLight3D. Adds a flatcolor * intensityterm to every lit fragment, regardless of geometry orientation. Keeps shadowed faces from going solid black.directional— a singlePhaser.Lights3D.DirectionalLight3D. An infinitely distant source (like a sun) with a colour, intensity and normalised direction.points[]— up to fourPhaser.Lights3D.PointLight3D. Positional omnidirectional lights with a radius and a smooth falloff.spots[]— up to fourPhaser.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 callPreset details:
studioclears 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.moodyclears point and spot lights and applies a warmer, darker rig. Use it for scenes where fog and tinted light are part of the mood.noneclears 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.
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.
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'sflatqualifier 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';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.
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.
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.
| 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.
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.
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').
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)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 matchingMesh3D/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.
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.
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.
Each import contributes three new objects:
asset.animations[]—GLTFAnimationCliptemplates, shared by every instance of the asset. Each clip carriesname,duration(seconds), a list ofsamplers(input,output,interpolation) and a list ofchannels(samplerIndex,nodeIndex,path). Channels on unsupported paths (weights) are dropped during parsing and recorded inasset.warnings[].asset.skins[]—GLTFSkinTemplaterecords withinverseBindMatrices(Float32Arrayof 16×jointCount floats, column- major) andjoints[](glTF node indices). Shared by every instance.root.mixer/root.animations— allocated per instance by the factory. The mixer ownsplay / stop / crossFade / updateand hooks into the SceneUPDATEevent whenautoUpdate !== 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.
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 is a subclass of Mesh3D that keeps:
joints— a reference list ofObject3Dnodes in the instance hierarchy (up toSkinnedMesh3D.MAX_JOINTS === 64).inverseBindMatrices— shared with the source asset. Never mutate.jointMatrices— a 64×16 float buffer updated once per frame fromjoint[j].getWorldMatrix() * inverseBindMatrices[j], then uploaded verbatim asu_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.
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 });
});
}- 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.
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
boundingSphereso the camera test passes when the spawn area is in view, and use multipleInstancedMesh3Dchunks 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 materialskinned: trueon 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.
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.
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);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 tothis.cameras3d.main.roots— optional object or object array to test instead of the whole 3D display list.ray— optional reusableRay3Dinstance 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
pickSphereunless you provide a better one. - No layer masks yet; pass an explicit
rootslist to scope a query.
The renderer runs two passes per scene:
- Opaque pass. All materials with
transparent === false(the default). Sorted front-to-back to help early-Z discard overdraw. Depth write on, blending off. - 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, orcolor[3] < 1andconfig.transparentwas 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
});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).
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".
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.
CubeandPlaneship with tight bounding spheres.- For raw
Mesh3Dinstances, providegeometry.boundingSphereexplicitly, or setcamera.frustumCulling = falseto 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.
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.
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).
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).
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).
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).
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 |
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.
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.
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.WebGL3Dis included.Phaser.Cameras.ThreeDis included (base camera, perspective, orthographic, frustum, manager plugin).Phaser.GameObjects3Dis included (Object3D, Mesh3D, Cube, Plane, factory plugin).Phaser.Lights3Dis included (Light3D, AmbientLight3D, DirectionalLight3D, PointLight3D, LightManager3D).Phaser.Loader3Dis included (GLTFParser, GLTFAsset, GLTFFile).this.load.gltfandthis.cache.gltfbecome available.Phaser.WEBGL3Dis a valid renderer type.this.cameras3d,this.add3Dandthis.lights3dare 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 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.add3Dandthis.lights3dwith auto-manageddisplayList3D. Object3Dscene graph with parent / children and world matrices.CubeandPlaneprimitives 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) withthis.load.gltf/this.add3D.gltf(key, options), PBR downgrade, node hierarchy andKHR_lights_punctualimport. - 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*_skinnedis 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 intoasset.warnings[]today so artists can see what fell off. (alphaMode: MASKis 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
WEBGLrenderer if you need HUD/UI.
See examples/vite-3d/ for a minimal end-to-end integration.