A Gameable character in your three.js app
Add a studio character to your existing three.js scene and render loop. Its gaussian splats (about 300,000) follow its skeleton and face rig on the GPU. You get an Object3D, named body clips and Apple ARKit's 52 facial blendshapes. No engine, ECS or WebAssembly setup is needed.
Requirements
- WebGPU first. three's
WebGPURendererruns on WebGPU where the browser has it (current Chrome and Edge, Safari 26 and later) and falls back to WebGL2 where it does not; the character draws on both, slower on WebGL2. See Without WebGPU. - three 0.186 (any 0.186 patch: the packages accept
>=0.186.0 <0.187.0), imported fromthree/webgpu(renderer, scene, materials) andthree/addons/...(controls, loaders). The classicWebGLRendereris not supported. - The character's URL: the address of its
character.json, for examplehttps://<studio>/companion/public/<org>/<slug>/character.json(its stable address; see What it downloads). The studio's "Use in your app" panel shows it. The host must send CORS headers; the studio does.
Install
sh
npm install three@0.186 gameable
npm install -D vite typescript @types/three@0.186 @webgpu/typesthree@0.186 installs the latest 0.186 patch and records ^0.186.x, which stays within 0.186.
For a .tgz, copy it into your project (for example vendor/) before installing, so the recorded path works for everyone who clones it. The runtime is bundled inside; three is yours.
sh
npm install three@0.186 ./vendor/gameable-0.0.0.tgzNo Vite configuration is needed. In tsconfig.json, add "@webgpu/types" to compilerOptions.types and use "moduleResolution": "bundler".
A minimal vite build is about 1.2 MB minified (0.35 MB gzipped), including three, with no .wasm files. For Vite's 500 kB chunk warning, set build.chunkSizeWarningLimit above your largest chunk, for example 1500 kB.
The whole app
index.html:
html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:," />
<title>My scene</title>
<style>
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
background: #20232a;
}
</style>
</head>
<body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>src/main.ts:
ts
import {
Color,
DirectionalLight,
HemisphereLight,
Mesh,
MeshStandardMaterial,
PerspectiveCamera,
PlaneGeometry,
Scene,
Timer,
WebGPURenderer,
} from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { loadGameableCharacter } from 'gameable/three';
const CHARACTER_URL = 'https://<studio>/companion/public/<org>/<slug>/character.json'; // its stable address
// Your renderer, scene, camera, floor and lights: nothing here is specific to the character.
const renderer = new WebGPURenderer({ antialias: true });
renderer.setPixelRatio(devicePixelRatio);
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
await renderer.init(); // before loading the character
const scene = new Scene();
scene.background = new Color(0x20232a);
const camera = new PerspectiveCamera(35, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 1.4, 4.2);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.95, 0);
const floor = new Mesh(
new PlaneGeometry(12, 12),
new MeshStandardMaterial({ color: 0x5b6470, roughness: 0.9 }),
);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
scene.add(new HemisphereLight(0xffffff, 0x404040, 1.2));
const sun = new DirectionalLight(0xffffff, 2);
sun.position.set(3, 5, 2);
scene.add(sun);
// The character.
const character = await loadGameableCharacter(renderer, CHARACTER_URL);
scene.add(character.object3D); // feet at the origin, facing +Z
character.play('idle');
character.lookAt(camera); // eyes and head follow the visitor
const timer = new Timer();
void renderer.setAnimationLoop((time) => {
timer.update(time);
controls.update();
character.update(timer.getDelta(), camera); // every frame, before drawing
renderer.render(scene, camera);
});
// Later, from a button or a timer:
// character.play('wave');Run npm run dev ("dev": "vite" in package.json). After downloading about 29 MB, cached for later loads, the character stands on the floor and idles.
At a glance
loadGameableCharacter(renderer, url, options?) resolves to the character. Its options:
| Option | Default | What it does |
|---|---|---|
clip | idle | The first clip. A name the character does not have fails the load (load-failed). |
tint | white | Multiplies its colour, to fit your light (below). |
exposure | 1 | Multiplies its brightness. |
castShadow | false | Casts a shadow into your scene's shadow maps. |
mouth | false | Draws the inside of the mouth (teeth, gums, tongue) when the lips part. |
mouthMotion | frame | studio: translate mouth expression; cap the lip band only while drawing the interior. |
mouthMesh | omitted | plain or textured: loads the frozen mouth mesh and enables the mouth draw. |
onProgress | (loadedBytes, totalBytes) as the files arrive. | |
fetch | the global one | Fetches every file with this (cookies, a proxy). |
signal | An AbortSignal that cancels the download. | |
allowWebGL | true | Draws on the WebGL2 fallback; false refuses it (Without WebGPU). |
allowSoftwareRenderer | false | Draws even with no GPU (software); off, the load throws software-renderer. |
attachPass | true | Attaches the pass with scene hooks; false: you drive it (Placing it). |
lighting | false | Reads each point's surface direction at load, for a shared cloud's setLighting. |
height | as exported | Draws the character at its real height, metres (At a real height). |
The character:
| Member | What it does |
|---|---|
object3D | Add it to your scene; move, turn and parent it. |
measures, scale | Its measures as drawn (eyes, head top, crown, hips, stature, width); the size factor. |
update(dt, camera) | Every frame, before renderer.render. |
clips, clip, play(name, { fade, loop }) | The clips it has, the one playing, and playing one (below). |
lookAt(target, { head, eyes }) | Eyes, head and a little of the body follow a target; null stops. |
expressionSpace | The head's expression kind and dimension. |
setExpression('arkit52', weights) | The face, in ARKit's 52 blendshapes. |
setExpression('native', weights) | The face, directly in the head's own expression space. |
setTint(color), setExposure(n), castShadow | Change the light fit and the shadow at any time. |
setJointAffines(names, affines | null) | Copied studio surface proportions on centres and covariances; null clears. |
setEyeShear(frame | null) | Calibrated virtual-eye volumes and shear; null restores procedural eyes. |
setBonePose(pose | null) | Your own pose on the body each frame (your physics, your fight maths); null: the clips again. |
dispose() | Removes it and frees its GPU memory. |
At a real height
The studio makes every character about 1.75 m tall. height draws it at the person's height: points, joints, shadow and measures scaled together from the floor between its feet, against the crown of its own points (hair included). scale is the factor; never type a height in.
ts
const logan = await loadGameableCharacter(renderer, url, { height: 1.88 }); // scale 1.0994Moving an app from WebGLRenderer
- Import from
three/webgpuinstead ofthree(renderer, scene, cameras, lights, materials); addons staythree/addons/.... Create aWebGPURendererandawait renderer.init()before the first render and before loading the character. - three's built-in materials (
MeshStandardMaterial,MeshBasicMaterial,MeshPhysicalMaterial, …), textures (CanvasTexturetoo), lights and shadow maps keep working as they are: only the imports change. ShaderMaterial,RawShaderMaterialandonBeforeCompiledo not run onWebGPURenderer: rewrite them as node materials (TSL).EffectComposerpost-processing does not either; three'sRenderPipelinereplaces it.PCFSoftShadowMapdoes not exist there (it warns and usesPCFShadowMap): setrenderer.shadowMap.type = PCFShadowMap, orVSMShadowMapfor softer edges.
Placing it
character.object3D has its origin between the feet, uses metres and faces +Z. Set its position and rotation.y, or parent it to another object. Each update finds its current parent scene; render that Scene to draw it.
On the first update, a scene.onBeforeRender pass draws your opaque scene's depth, then the characters against it. A transparent composite brings them into your render. Their splats blend in sRGB, matching their training and studio appearance. Your loop and three's RenderPipeline post-processing work as usual.
- Your scene's opaque objects are drawn once more per frame, depth only, in one extra render: each with its own material's side, so a wall seen from behind hides nothing.
- The pass uses the camera layers 30 and 31 for its own renders: leave them free.
- Like any splat, the characters are drawn after your transparent objects (render order 1000): a pane of glass in front of a character is drawn under it.
- A cut-out (
maporalphaMapwithalphaTest: foliage, a fence) hides the character only where its texture is solid, as it does in your render. - If you assign
scene.onBeforeRenderorscene.onAfterRenderyourself, do it before the firstupdate(the pass keeps and calls yours), or wrap the one already there. Or attach the pass yourself, without hooks: load with{ attachPass: false }, thenconst pass = attachManualPass(renderer, scene)(from this package) and callpass.begin(camera)just beforerenderer.render(scene, camera)andpass.end()just after. - A reflection or other render of the scene made during your render shows no character.
- Use one renderer and the camera passed to
update. Another renderer or a second camera in the same frame (split screen) draws the splats out of order.
To find the head (to aim a close-up camera at the face, or attach a hat), use the skeleton's Head bone. It follows every clip and wherever you move object3D; read it after character.update:
ts
const head = character.object3D.getObjectByName('Head')!; // a studio character's head bone
const headPosition = head.getWorldPosition(new Vector3());
camera.lookAt(headPosition.x, headPosition.y + 0.13, headPosition.z); // the eyes: about 13 cm upThe Head bone meets the neck (about 1.48 m on a standing 1.72 m character); eyes are 13 cm above it, the mouth 5 cm. head.add(hat) makes the hat follow it.
Playing clips
character.clips lists every clip the character can play. Studio characters have idle, walk, run and wave, plus the studio's shared pack (names starting with ual_, such as ual_dance, ual_jump, ual_sitting_idle).
ts
character.play('wave'); // cross-fades from the current clip over 0.25 s
character.play('walk', { fade: 0.5 }); // a longer cross-fade
character.play('ual_dance', { loop: true }); // force looping
console.log(character.clip); // the clip playing now- It starts in
idle: a gentle standing loop of about 2.5 s (the weight shifts, the arms and the head sway a few degrees), with its blinks andlookAton top. For anything more, play another clip. Pass{ clip: 'wave' }as the third argument ofloadGameableCharacterto start with another. - A clip the studio marks as one-shot (
wave) plays once and cross-fades back to the looping clip that was playing before it. Pass{ loop: true }or{ loop: false }to override. playwith the clip that is already playing does nothing: awavethat is still waving runs on to its end (and returns as before), so an app can callplay('wave')on every greeting without checkingcharacter.clipfirst.playwith a name the character does not have throws, and the message lists the names it does have.- Clips play in place:
walkandrundo not moveobject3D. Move it yourself.
Looking at something
ts
character.lookAt(camera); // follows the camera as it moves
character.lookAt(shelf); // any Object3D, read every frame
character.lookAt(new Vector3(2, 1.5, 0)); // a point in the world (change it and it follows)
character.lookAt(camera, { head: 0.3, eyes: 1 }); // mostly with the eyes
character.lookAt(null); // back to the clip's own headThe eyes lead, the head follows and the upper body turns a little, over whatever clip plays, as the studio's "Looks at you" does by default: the face turns 55 % of the way (a tenth of the look is the upper body's), the eyes take the rest. The head turns at most 45 degrees to the side (it gets there only for a target well off to the side) and 25 degrees up or down, the eyes 35 degrees more, so a target behind the character is looked at over the shoulder, as far as that goes.
Passing a studio surface frame
These independent overrides are off by default. They copy their inputs, hold until replaced, and work on the TSL and WGSL compute deformations, including shared splat clouds. The vertex-only webgl-skin fallback rejects non-null overrides; clearing with null is safe.
ts
// Studio pcSkinTune / rigPose.tuneAffine: 12 floats per named joint, three rows of [A-I | offset].
character.setJointAffines(
['Hips'],
new Float32Array([
0.1,
0,
0,
0, // 10% wider in rest-space x
0,
0,
0,
0,
0,
0,
0,
0,
]),
);
character.setJointAffines([], null);
// pcGazeFrame supplies each effective radius as rho * eye.radius and each shearVector result.
character.setEyeShear({
left: { centre: [0.03, 1.6, 0], radius: 0.014, shear: [0.2, 0, 0] },
right: { centre: [-0.03, 1.6, 0], radius: 0.014, shear: [0.2, 0, 0] },
fade: [1, 1.35], // reach fade, in eye radii; these are also the defaults
});
character.setEyeShear(null);All values use the package's rest frame and metres, before object3D placement or scale. Convert studio centres, vectors and affines into that frame before submitting them. The engine does no camera calibration or proportion solving. Joint names must be unique package names, with exactly 12 finite floats each; missing names have identity deltas. Each call replaces the whole affine table. null and all-zero affine deltas take the original shader path.
The formula matches studio viewer/pcSkin.ts's modifySplatCenter and gmTuneCov: D = sum(w[k] / sum(w) * delta[joint[k]]), p' = p + D * [p,1], A = I + D.xyz, Sigma' = A * Sigma * transpose(A), before body skinning. An unweighted row uses joint slot 0 (Root). This changes the surface only. Continue passing final tuned skeleton transforms through setBonePose; this API does not solve joint positions. Surface tune and gaze shear leave the existing spherical-harmonic rotation basis unchanged.
Eye shear matches viewer/gazeShader.ts's gmGazeCenter / gmGazeCov. For the first eye containing rest centre p, rel = p - centre, d = length(rel), p += (radius-d)*shear. The covariance takes J = I + outerProduct(shear, -rel/max(d,1e-5)); outside both eyes it is unchanged. The studio's 1.5-sigma reach fade runs after gaze and before the affine tune. An explicit frame suppresses procedural eye requests, including a zero-shear frame; null restores them. Leave eyeLook expression shapes at rest when steering with calibrated shear.
Auxiliary swinging parts need an exporter change before a runtime setter can animate them. Current chain/jiggle exports carry no auxiliary slot rows: studio gpu-worker/worker/engine_package.pyfold_slots folds chain/jiggle weights onto anchor joints, remap_weights consumes that result, and assemble_package and verify_package require every binding index to name a skeleton joint. The original slot identities and weights cannot be recovered from that merged export. The exporter must preserve the original per-row slot indices/weights (including OGS level rows), publish a slot table with count, anchor ownership and rest/default skin matrices in character.json, and extend binding writing/validation in those functions for the declared slots. Keep these slots distinct from the existing hair/ slots. No setExtraJoints API is exposed until that data exists.
Fitting it into your scene's light
ts
const character = await loadGameableCharacter(renderer, url, {
tint: '#ffe6cc', // multiply its colour: warmer for a warm room (white: as captured)
exposure: 1.1, // and its brightness
castShadow: true, // a real shadow in your scene's shadow maps
});
character.setTint('#ffffff'); // change either at any time
character.setExposure(1);
character.castShadow = false;The character is lit as it was captured: your scene's lights do not light it. tint and exposure multiply its colour in linear light, to match it to a warm or dim scene. A starting point for tint: your main light's colour taken about half way to white (a lantern's #ffb060 gives #ffd8b0); then exposure above 1 for a room brighter than the capture, below 1 for a dim one. With several lights, take the colour of the one that lights the character most: usually the key light, the one that casts the shadows. Judge it beside a mesh lit by the same light. Both apply before your tone mapping, like every other colour in the scene.
scene.fog does not apply to the character: in a foggy scene it keeps its full colour at any distance, so it reads brighter than the fogged scene around it far away. Keep it within the fog's near distance where you can; tint and exposure can dim it toward a darker fog as it walks away (they multiply, so they cannot lighten it toward a pale one).
Characters are authored in the Gameable studio without tone mapping, so with three's default (NoToneMapping) it shows the studio's colours. Your renderer's tone mapping applies to it as to everything else on screen: choose the one that suits your scene.
castShadow draws the character's body mesh (body.glb in the package, skinned to the same skeleton and following every clip) into your scene's shadow maps, never to the screen. It needs renderer.shadowMap.enabled = true and a light with castShadow, and costs one skinned draw per shadow-casting light each frame (six for a point light), so it is off by default. The mesh is made the first time you turn it on; the file itself always comes with the package. The character does not receive shadows.
The face
ts
// ARKit's 52 blendshape names, any subset; the rest are 0.
character.setExpression('arkit52', { mouthSmileLeft: 0.8, mouthSmileRight: 0.8 });
character.setExpression('arkit52', { jawOpen: 0.5, browInnerUp: 0.6 });
// Or all 52 as numbers in ARKit order.
character.setExpression('arkit52', new Float32Array(52));Weights are in [0, 1]: a weight outside it is clamped (jawOpen: 2 is jawOpen: 1, a negative or NaN is 0). They hold until the next call; call it every frame to animate the face (for example from lip-sync). The character blinks on its own on top, every few seconds. A name that is not one of ARKit's 52 (eyeBlinkLeft, jawOpen, mouthSmileRight, … as Apple spells them, in any letter case) throws. From MediaPipe's face landmarker, pass its categories by name and leave out its first one, _neutral, which is not an ARKit blendshape.
For lip-sync in the head's own space, use native. Automatic blinks continue on top: only the blink is mapped from ARKit and added as a delta from the mapped rest pose. expressionSpace reports the head's kind (gnm, gnm68 or arkit52) and dim. Native input must have exactly dim coefficients or it throws. Signed values are preserved; non-finite values become 0. Frames are copied into a reusable buffer. ARKit heads refuse native input. Switch between native and ARKit on successive calls as needed.
ts
if (character.expressionSpace.kind !== 'arkit52') {
const frame = new Float32Array(character.expressionSpace.dim);
frame[0] = -0.5;
character.setExpression('native', frame);
}The face plays the weights through a hand-made mapping from ARKit's blendshapes to the face model, which moves it gently. On a studio character, measured as the most any point of the face moves: a full blink moves the lids about 4 mm (they do not close), jawOpen: 1 about 11 mm, a smile about 20 mm. That is how it is, not a fault. A package that carries its own face table (face.arkit in character.json, the file arkit_to_gnm.bin) plays that instead, and moves the face as the studio's does (a blink about 9 mm, closing the eyes; jawOpen: 1 about 35 mm); most packages do not carry one. The eyeLook* names also turn the eyeballs.
Keep your scene running while it loads
The examples above await the load before the loop starts, which leaves the page blank while the files download. To show your scene (and a placeholder) straight away, start the loop first and add the character when it arrives (placeholder and bar are your own):
ts
import { loadGameableCharacter, type GameableCharacter } from 'gameable/three';
await renderer.init(); // still first: the loop and the load both need it
let character: GameableCharacter | null = null;
renderer.setAnimationLoop((time) => {
timer.update(time);
const dt = timer.getDelta();
character?.update(dt, camera);
renderer.render(scene, camera);
});
loadGameableCharacter(renderer, CHARACTER_URL, {
onProgress: (loaded, total) => (bar.value = total > 0 ? loaded / total : 0),
})
.then((loaded) => {
scene.remove(placeholder);
scene.add(loaded.object3D);
loaded.play('wave');
character = loaded;
})
.catch((error: unknown) => {
// keep the placeholder; see "Without WebGPU" for the error codes
console.warn(error);
});Loading options
ts
const character = await loadGameableCharacter(renderer, url, {
clip: 'idle', // the first clip
signal: controller.signal, // an AbortController's signal, to cancel the download
// Progress, in bytes: for a loading bar.
onProgress: (loaded, total) => (bar.value = total > 0 ? loaded / total : 0),
// Fetch every file with this instead of the global fetch, e.g. to send cookies:
fetch: (input, init) => fetch(input, { ...init, credentials: 'include' }),
});onProgress counts the files as they are, not as they travel: a server that compresses them (the studio does for its packed copies) sends fewer bytes than total, so the bar still ends at 100 % but the download is shorter than it says. The last call has loaded === total; the character is then built on the GPU (well under a second on a desktop) before the promise resolves.
Every file the character.json names is fetched relative to it and checked against the SHA-256 it lists; a file that does not match is refused and the load fails. That check is what makes loading a character from another site safe. The URL may redirect (a stable address that always answers with the character's current version): the files are then fetched beside the character.json the redirect ended at. A fetch you pass must follow redirects and return a Response whose url says where it ended, as the browser's fetch does.
What it downloads, and which copy to use
A character loads in stages: character.json; then its files, each checked against its SHA-256 (this is what onProgress counts); then the packed files are unpacked and everything is put on the GPU (a moment on a desktop); then loadGameableCharacter resolves and the next update draws it.
The files, for a version 2 character:
| File | What it is |
|---|---|
character.ply (or packed: character.psp) | the gaussians: the character itself |
bindings.bin (or packed: bindings.pbn) | which bones and which face triangle each gaussian follows |
head.aosrig | the face model: the head and how each expression moves it (42 MB) |
skeleton.json, clips.json | the skeleton and the character's own clips |
body.glb | the body mesh (2 MB): never drawn; castShadow draws it into your shadow maps |
arkit_to_gnm.bin | the face's own ARKit table, when the package carries it (most do not) |
mouth_hidden.bin | the face's gaussians inside the closed mouth, which give way as the lips open |
../soma_ready_clips.json | the shared clip pack (17 MB, 2 MB compressed), fetched once per page |
character.clips is the character's own clips (the names character.json's clips lists) plus the shared pack's (about 125 more, ual_*), so it is longer than character.json suggests.
The studio packs splats and bindings (.psp, .pbn, no visible loss) and compresses downloads:
- full: typically 88 MB of files, 29 MB downloaded;
- light: the phones' 512 level, fewer splats; typically 73 MB of files, 19 MB downloaded.
character.json and onProgress count uncompressed sizes.
Use the stable address, https://<studio>/companion/public/<org>/<slug>/character.json, which redirects to the current version. ?quality= accepts full, light (falls back to full if absent), or auto (default: light for phones, full otherwise). Versioned files/ URLs stay valid after republishing, but keep pointing to the old version.
On phones: a full copy needs a few hundred megabytes of memory (the files, then the GPU buffers: roughly 170 MB of GPU memory for a 300,000-gaussian character with its face); the phones' copy needs a fraction of that. WebGPU needs a secure context: a page opened on a phone over http://<your LAN IP> has no WebGPU (it falls back to WebGL2, slower); serve it over HTTPS, or forward the port to the phone so it opens localhost.
Without WebGPU
On a browser without WebGPU, three's renderer falls back to WebGL2 and the character draws there too: the same code runs as transform feedback, slower, and the gaussians' draw order is sorted on the CPU and lags the camera by a frame or a few (visible only when the camera turns fast). To show something else there instead, pass allowWebGL: false: the load then throws webgpu-required.
ts
import { GameableCharacterError, loadGameableCharacter } from 'gameable/three';
try {
const character = await loadGameableCharacter(renderer, CHARACTER_URL, { allowWebGL: false });
scene.add(character.object3D);
} catch (error) {
if (error instanceof GameableCharacterError && error.code === 'webgpu-required') {
// No WebGPU, and you asked for no fallback: show a message, a picture, or a placeholder.
} else {
throw error;
}
}Without hardware acceleration, software rendering (SwiftShader, llvmpipe, Windows' basic driver) is too slow. Loading fails before downloading with software-renderer; show a placeholder. isSoftwareRenderer(renderer) checks up front; allowSoftwareRenderer: true overrides it.
error.code is one of:
| Code | Meaning | What to do |
|---|---|---|
webgpu-required | The renderer is on WebGL2 and you passed allowWebGL: false. | Show something else. |
renderer-not-ready | renderer.init() has not finished. | await renderer.init() first. |
load-failed | Not fetched, a failed SHA-256 check, not a character, or no clip clip. | Check the URL and CORS; error.message says which. |
software-renderer | The browser has no GPU to draw with and renders in software. | Show something else. |
Credit
Show a credit with the character: the words "I made this with Gameable", linking to https://app.gameable.com/.
Removing it
ts
character.dispose(); // removes it from the scene and frees its GPU memoryTroubleshooting
- Nothing appears. Call
character.update(dt, camera)every frame: the character stays hidden until the first update and does not animate without it. Check the console for a thrownGameableCharacterError. - Slow, or on WebGL2 on a browser that has WebGPU. Use
WebGPURendererfromthree/webgpu, do not passforceWebGL: true, and serve the page fromlocalhostor HTTPS (WebGPU needs a secure context).renderer.backend.isWebGLBackendsays which one you got. - A CORS error in the console. The host of
character.jsonmust sendAccess-Control-Allow-Origin. Published studio characters do. - Two copies of three. Install three 0.186 once; the character package uses your copy (
npm ls threeshows one, the othersdeduped). - The character disappears after I set
scene.onBeforeRender. Assigning it replaces the character's pass. Set yours before the firstcharacter.update, or wrap the one already there. - Other splat renderers. A splat scene drawn by another library (or by three's own
GaussianSplat) writes no depth: it and the characters are ordered as whole objects, by distance, not gaussian by gaussian where they overlap.
What it does not do yet
Talking (voice and lip-sync need the studio's relay). The character is lit as it was captured, not by your scene's lights (see Fitting it into your scene's light), and receives no shadows. The inside of the mouth (teeth, gums, tongue) is off by default, as on the studio's stage; { mouth: true } draws it for a package with teeth.json, teeth.ply, teeth.bin. For a frozen mesh, use { mouthMesh: 'plain' } or { mouthMesh: 'textured' }: both enable the mouth and load mouth.json / mouth.glb; textured also loads mouth_texture.png.