Three.js rendering library
3D Gaussian Splatting for Three.js.
Render 3D Gaussian Splatting scenes with a focused library built for Three.js.
Getting started
Browser
Save this as index.html, place scene.sog beside it, and serve the directory with a local web server.
<canvas id="canvas"></canvas>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.175.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.175.0/examples/jsm/",
"threedgs": "https://cdn.jsdelivr.net/npm/threedgs@latest/threedgs.js"
}
}
</script>
<script type="module">
import { PerspectiveCamera, Scene, WebGLRenderer } from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { Sog, SplatMesh } from 'threedgs'
const canvas = document.querySelector('#canvas')
const renderer = new WebGLRenderer({ canvas, antialias: false })
renderer.setSize(innerWidth, innerHeight)
const scene = new Scene()
const camera = new PerspectiveCamera(60, innerWidth / innerHeight, 0.01, 1000)
camera.position.z = 3
const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true
controls.autoRotate = true
const sog = await Sog.load('/scene.sog')
const splats = SplatMesh.create(sog)
scene.add(splats)
renderer.setAnimationLoop(() => {
controls.update()
renderer.render(scene, camera)
})
</script>
npm
For a bundled application, install threedgs and Three.js from npm.
Render data
SplatRenderData is the seam between parsed source data and SplatMesh: it supplies geometry, uniforms, GLSL decoding, sortable positions, draw-count updates, and resource disposal.
Interface
Implement this interface when a source format needs its own rendering adapter:
import type { BufferGeometry } from 'three'
export interface SplatRenderData {
// Total number of splats represented by this adapter.
readonly vertexCount: number
// Instanced geometry with a uint `drawOffset` attribute.
// Within each instance, every four consecutive vertex ids represent one splat.
readonly geometry: BufferGeometry
// Values exposed to the shader under `renderData`.
readonly uniforms: Record<string, unknown>
// GLSL inserted after the shared `RenderState` and `Splat` declarations.
// Must define `Splat decodeSplat(int dataIndex)`. `Splat.index` is stable
// for modifiers; position is local-space; rotation is a normalized
// scalar-first `(w, x, y, z)` quaternion; scale is positive and linear;
// color contains RGB with opacity in alpha.
readonly shaderParts: string
// `vertexCount * 3` flat local-space XYZ values used for sorting.
getPositions(): Float32Array
// Draw the first `count` splats.
setDrawCount(count: number): void
// Release owned resources; safe to call more than once.
dispose(): void
}
Each adapter instance is owned by one SplatMesh. Register its factory against the source data's exact constructor:
SplatMesh.registerRenderData(CustomData, (data, options) =>
new CustomRenderData(data, options)
)
Registered render data
The package registers support for these formats by default.
| Format | Description |
|---|---|
PLY |
Industry-standard uncompressed format for source, editing, and interchange. |
Compressed PLY |
PlayCanvas's compressed PLY format, detected and decompressed automatically when read. |
SOG |
PlayCanvas's bundled super-compressed format for web delivery. |
Render modifiers
SplatRenderModifier changes a splat effect or appearance in the shader without rewriting or copying the source model data.
- Add custom
uniformsfor animation and runtime controls. - Inject GLSL through
modifySplat,modifySplatProjection, ormodifyFragmentColor. - Control sub-pixel splat culling with
smallSplatCulling.
Each example includes its complete implementation and usage. Choose one to restart it in the interactive preview.
import { SplatRenderModifier } from 'threedgs'
const modifier = new SplatRenderModifier({
uniforms: {
effect: {
value: {
center: splats.worldToLocal(controls.target.clone()),
progress: 0,
},
},
},
shaderParts: {
vertex: {
declarations: `
struct EffectUniforms {
vec3 center;
float progress;
};
uniform EffectUniforms effect;
float radialProgress(vec3 position, float phase) {
float positionProgress =
clamp(length((position - effect.center).xz) / 10.0, 0.0, 1.0);
float start = min(positionProgress, 0.92);
return clamp((phase - start) / 0.08, 0.0, 1.0);
}
`,
modifySplat: `
bool modifySplat(inout Splat splat) {
float pointReveal = radialProgress(
splat.position,
pow(clamp(effect.progress / 0.55, 0.0, 1.0), 2.0)
);
vec2 offset = (splat.position - effect.center).xz;
vec2 direction = length(offset) > 0.0001
? normalize(offset)
: vec2(0.0);
splat.position.xz += direction
* sin(pointReveal * 3.14159265)
* 0.18;
splat.color.a *= pointReveal;
return pointReveal > 0.0;
}
`,
modifySplatProjection: `
void modifySplatProjection(
const in Splat splat,
inout vec2 splatOffset
) {
float gaussianReveal = radialProgress(
splat.position,
clamp((effect.progress - 0.55) / 0.45, 0.0, 1.0)
);
vec4 center = renderState.projectionMatrix
* renderState.modelViewMatrix
* vec4(splat.position, 1.0);
vec2 screenOffset = splatOffset
* renderState.viewport
/ center.w;
vec2 pointOffset = length(screenOffset) > 0.0001
? normalize(screenOffset) * 6.0
/ renderState.viewport * center.w
: vec2(0.0);
splatOffset = mix(pointOffset, splatOffset, gaussianReveal);
}
`,
},
},
})
splats.setRenderModifier(modifier)
const startTime = performance.now()
renderer.setAnimationLoop((time) => {
const elapsed = Math.min((time - startTime) / 9000, 1)
const progress = elapsed * elapsed * (3 - 2 * elapsed)
modifier.uniforms.effect.value.progress = progress
modifier.invalidateUniforms()
controls.update()
renderer.render(scene, camera)
})
import { Vector3 } from 'three'
import { SplatRenderModifier } from 'threedgs'
const createMagicModifier = ({ orbitTarget = new Vector3() } = {}) =>
new SplatRenderModifier({
smallSplatCulling: false,
uniforms: {
effect: { value: { orbitTarget } },
},
shaderParts: {
vertex: {
declarations: `
struct EffectUniforms {
vec3 orbitTarget;
};
uniform EffectUniforms effect;
vec3 hash(vec3 p) {
p = fract(p * 0.3183099 + 0.1);
p *= 17.0;
return fract(vec3(
p.x * p.y * p.z,
p.x + p.y * p.z,
p.x * p.y + p.z
));
}
vec4 applyMagicEffect(vec3 pos, vec3 originalScale) {
float t = renderState.animateTime + 4.5;
float s = smoothstep(0., 10., t - 4.5) * 10.;
float l = min(length(pos.xz), 3.0);
float border = abs(s - l - .5);
vec3 magicPos = pos * (1. - .2 * exp(-20. * border));
magicPos = t > 18.0
? magicPos
: magicPos + .1 * magicPos.xyz
* smoothstep(s - .5, s < 10. ? s : (t - 4.5), l + .5);
float at = atan(pos.x, pos.z) / 3.1416;
float alphaStep = step(at, t - 3.1416);
float alphaBoost = exp(-20. * border)
+ exp(-50. * abs(t - at - 3.1416)) * .5;
return vec4(magicPos, alphaStep + alphaBoost);
}
`,
modifySplat: `
bool modifySplat(inout Splat splat) {
vec3 originalPos = splat.position;
vec3 originalScale = splat.scale;
vec4 result = applyMagicEffect(
originalPos - effect.orbitTarget,
originalScale
);
splat.position = result.xyz + effect.orbitTarget;
float t = renderState.animateTime + 4.5;
float s = smoothstep(0., 10., t - 4.5) * 10.;
float l = min(length(originalPos.xz), 3.0);
splat.scale = mix(
originalScale,
vec3(0.0002),
smoothstep(
s - .5,
s < 10. ? s : (t - 4.5),
min(l + .5, 10.0)
)
);
splat.color.a *= t < 5.5
? result.w * (t - 4.5)
: result.w;
return true;
}
`,
},
},
})
const center = splats.worldToLocal(controls.target.clone())
const modifier = createMagicModifier({
orbitTarget: center,
})
splats.setRenderModifier(modifier)
const startTime = performance.now()
renderer.setAnimationLoop((time) => {
const elapsed = Math.min((time - startTime) / 9000, 1)
splats.animateTime = elapsed * elapsed * (3 - 2 * elapsed) * 15
controls.update()
renderer.render(scene, camera)
})
import { Vector3 } from 'three'
import { SplatRenderModifier } from 'threedgs'
const createRainModifier = ({
orbitTarget = new Vector3(),
rainTopHeight = 5,
} = {}) => new SplatRenderModifier({
uniforms: {
effect: { value: { orbitTarget, rainTopHeight } },
},
shaderParts: {
vertex: {
declarations: `
struct EffectUniforms {
vec3 orbitTarget;
float rainTopHeight;
};
uniform EffectUniforms effect;
vec3 hash(vec3 p) {
p = fract(p * 0.3183099 + 0.1);
p *= 17.0;
return fract(vec3(
p.x * p.y * p.z,
p.x + p.y * p.z,
p.x * p.y + p.z
));
}
vec4 applyRainEffect(vec3 worldPos, vec3 originalScale) {
vec3 h = hash(worldPos);
float t = renderState.animateTime;
float s = pow(
smoothstep(
0.,
5.,
t * t * .1
- min(length(worldPos.xz), 3.0) * 2.
+ 1.
),
.5 + h.x
);
vec3 rainWorldPos = worldPos;
rainWorldPos.y = mix(
effect.rainTopHeight,
worldPos.y,
s
);
rainWorldPos.xz = mix(
worldPos.xz * 0.3,
worldPos.xz,
s
);
return vec4(rainWorldPos, s);
}
`,
modifySplat: `
bool modifySplat(inout Splat splat) {
vec3 originalPos = splat.position;
vec3 originalScale = splat.scale;
vec3 worldPos = (
renderState.modelMatrix * vec4(originalPos, 1.0)
).xyz;
vec3 worldTarget = (
renderState.modelMatrix
* vec4(effect.orbitTarget, 1.0)
).xyz;
vec4 result = applyRainEffect(
worldPos - worldTarget,
originalScale
);
vec3 rainWorldPos = result.xyz + worldTarget;
splat.position = (
renderState.inverseModelMatrix
* vec4(rainWorldPos, 1.0)
).xyz;
splat.scale = mix(
vec3(0.001),
originalScale,
pow(result.w, 6.0)
);
splat.color.a *= pow(result.w, 0.7);
return true;
}
`,
},
},
})
const center = splats.worldToLocal(controls.target.clone())
const modifier = createRainModifier({
orbitTarget: center,
rainTopHeight: 5,
})
splats.setRenderModifier(modifier)
const startTime = performance.now()
renderer.setAnimationLoop((time) => {
const elapsed = Math.min((time - startTime) / 9000, 1)
splats.animateTime = elapsed * elapsed * (3 - 2 * elapsed) * 15
controls.update()
renderer.render(scene, camera)
})
import { SplatRenderModifier } from 'threedgs'
const createTwisterModifier = ({ twisterHeight = 4 } = {}) =>
new SplatRenderModifier({
uniforms: {
effect: { value: { twisterHeight } },
},
shaderParts: {
vertex: {
declarations: `
struct EffectUniforms {
float twisterHeight;
};
uniform EffectUniforms effect;
vec3 hash(vec3 p) {
p = fract(p * 0.3183099 + 0.1);
p *= 17.0;
return fract(vec3(
p.x * p.y * p.z,
p.x + p.y * p.z,
p.x * p.y + p.z
));
}
mat2 rot(float a) {
float s = sin(a), c = cos(a);
return mat2(c, -s, s, c);
}
vec4 quatMultiply(vec4 q1, vec4 q2) {
return vec4(
q1.w * q2.xyz + q2.w * q1.xyz
+ cross(q1.xyz, q2.xyz),
q1.w * q2.w - dot(q1.xyz, q2.xyz)
);
}
vec4 applyTwisterEffect(
vec3 pos,
vec3 originalScale,
vec4 originalRotation
) {
vec3 h = hash(pos);
float t = renderState.animateTime;
float s = smoothstep(
0.,
8.,
t * t * .1 - min(length(pos.xz), 10.0) * 2. + 2.
);
vec3 twisterPos = pos;
if (length(originalScale) < .05) {
twisterPos.y = mix(
effect.twisterHeight,
pos.y,
pow(s, 2. * h.x)
);
}
twisterPos.xz = mix(
pos.xz * .5,
pos.xz,
pow(s, 2. * h.x)
);
float rotationTime = t * (1.0 - s) * 0.2;
twisterPos.xz *= rot(
rotationTime + pos.y * 20. * (1. - s)
* exp(-min(length(pos.xz), 10.0))
);
return vec4(twisterPos, s * s * s * s);
}
`,
modifySplat: `
bool modifySplat(inout Splat splat) {
vec3 originalPos = splat.position;
vec4 originalRotation = splat.rotation;
vec3 originalScale = splat.scale;
vec4 result = applyTwisterEffect(
originalPos,
originalScale,
originalRotation
);
splat.position = result.xyz;
splat.scale = mix(
vec3(0.0015),
originalScale,
pow(result.w, 6.0)
);
float spin = -renderState.animateTime
* 0.3
* (1.0 - result.w);
vec4 spinQ = vec4(
0.0,
sin(spin * 0.5),
0.0,
cos(spin * 0.5)
);
splat.rotation = quatMultiply(spinQ, originalRotation);
splat.color.a *= pow(result.w, 0.7);
return true;
}
`,
},
},
})
const modifier = createTwisterModifier({
twisterHeight: 4,
})
splats.setRenderModifier(modifier)
const startTime = performance.now()
renderer.setAnimationLoop((time) => {
const elapsed = Math.min((time - startTime) / 9000, 1)
splats.animateTime = elapsed * elapsed * (3 - 2 * elapsed) * 15
controls.update()
renderer.render(scene, camera)
})
import { Vector3 } from 'three'
import { SplatRenderModifier } from 'threedgs'
const createUnrollModifier = ({
orbitTarget = new Vector3(),
unrollGatherStrength = 0.8,
} = {}) => new SplatRenderModifier({
uniforms: {
effect: { value: { orbitTarget, unrollGatherStrength } },
},
shaderParts: {
vertex: {
declarations: `
struct EffectUniforms {
vec3 orbitTarget;
float unrollGatherStrength;
};
uniform EffectUniforms effect;
mat2 rot(float a) {
float s = sin(a), c = cos(a);
return mat2(c, -s, s, c);
}
vec4 applyUnrollEffect(vec3 pos, vec3 originalScale) {
float t = renderState.animateTime;
float l = min(length(pos.xz), 3.0);
vec3 unrollPos = pos;
unrollPos.xz *= rot(
(pos.y * 10. - 20.) * exp(-t * 3.)
);
unrollPos *= effect.unrollGatherStrength
+ (1. - exp(-t) * 2.)
* (1.0 - effect.unrollGatherStrength);
float alpha = step(0., t * .5 + max(pos.y, -1.))
* min(t * .5 - min(1., l), 1.);
return vec4(unrollPos, alpha);
}
`,
modifySplat: `
bool modifySplat(inout Splat splat) {
vec3 originalPos = splat.position;
vec3 originalScale = splat.scale;
vec4 result = applyUnrollEffect(
originalPos - effect.orbitTarget,
originalScale
);
splat.position = result.xyz + effect.orbitTarget;
float l = length(originalPos.xz);
splat.scale = mix(
vec3(0.001),
originalScale,
mix(
0.001,
1.,
min(
renderState.animateTime * .5
- min(1., l) * .1,
1.
)
)
);
splat.color.a *= result.w;
return true;
}
`,
},
},
})
const center = splats.worldToLocal(controls.target.clone())
const modifier = createUnrollModifier({
orbitTarget: center,
unrollGatherStrength: 0.8,
})
splats.setRenderModifier(modifier)
const startTime = performance.now()
renderer.setAnimationLoop((time) => {
const elapsed = Math.min((time - startTime) / 9000, 1)
splats.animateTime = elapsed * elapsed * (3 - 2 * elapsed) * 15
controls.update()
renderer.render(scene, camera)
})
After changing uniform values in place, call invalidateUniforms(). If shader snippets change, call invalidateDefinitions() to rebuild the material.
The mesh does not own the modifier. Detach it with setRenderModifier(null), then reuse it with another mesh if needed.
modifySplat receives local-space data. Sorting and coarse culling still use the source position; use renderState.modelMatrix and renderState.inverseModelMatrix for world-space effects.
After attaching a modifier, inspect the composed shader through splats.material.vertexShader and splats.material.fragmentShader.