Show all 25 aliases
loading manager, LoadingManager progress, loading screen three.js, loader bar, progress bar webgl, intro fade, overlay shader, fade in scene when loaded, black overlay plane, fullscreen quad, shader without matrices, uAlpha uniform, mixing html and webgl, html points on 3d model, annotation labels 3d, interest points, project 3d position to screen, Vector3 project camera, world position to pixels, occlusion test raycaster, hide label behind object, distanceTo camera, throttle network to test loading, how do I show loading progress, how do I attach html to a 3d object
LoadingManager: one progress source for all loaders
- Every asset counts toward loading: a glTF model is its geometry plus all its textures, an environment map is 6 images. A single
LoadingManager passed into each loader aggregates them all:
const loadingManager = new THREE.LoadingManager(
// Loaded: fires once, when everything is done
() => { /* start intro */ },
// Progress: fires per asset
(itemUrl, itemsLoaded, itemsTotal) => {
const progressRatio = itemsLoaded / itemsTotal
}
)
const gltfLoader = new GLTFLoader(loadingManager)
const cubeTextureLoader = new THREE.CubeTextureLoader(loadingManager)
- Progress callback arguments: the asset URL, count loaded so far, total count.
itemsLoaded / itemsTotal gives a clean 0 to 1 ratio.
- Testing locally is useless without throttling: in DevTools Network, check Disable cache and add a custom throttle profile slow enough to watch the bar fill. Without both, everything loads instantly from cache and the loader code never gets exercised.
Overlay: a fullscreen fade plane inside WebGL
- To fade the whole scene in, prefer a WebGL overlay over animating canvas CSS opacity or a black div. Keep it in the renderer with a plane that ignores the camera entirely.
- The trick is a ShaderMaterial whose vertex shader skips the projection and modelView matrices. Vertices land directly in clip space, so the plane always covers the view no matter where the camera is:
const overlayGeometry = new THREE.PlaneGeometry(2, 2, 1, 1)
const overlayMaterial = new THREE.ShaderMaterial({
transparent: true,
uniforms: {
uAlpha: { value: 1 }
},
vertexShader: `
void main()
{
gl_Position = vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uAlpha;
void main()
{
gl_FragColor = vec4(0.0, 0.0, 0.0, uAlpha);
}
`
})
const overlay = new THREE.Mesh(overlayGeometry, overlayMaterial)
scene.add(overlay)
- Geometry must be
2 x 2, not 1 x 1: clip space runs from -1 to +1, and a size-1 plane spans only -0.5 to +0.5, a quarter of the screen.
transparent: true is mandatory or the alpha channel of gl_FragColor is ignored and the overlay stays fully opaque. This is the most common "why is my alpha not working" bug.
- Start
uAlpha at 1 (fully black) and drive it from JavaScript via the uniform.
Intro fade
- In the manager's loaded callback, tween the uniform to 0:
import { gsap } from 'gsap'
const loadingManager = new THREE.LoadingManager(
() => {
window.setTimeout(() => {
gsap.to(overlayMaterial.uniforms.uAlpha, { duration: 3, value: 0, delay: 1 })
loadingBarElement.classList.add('ended')
loadingBarElement.style.transform = ''
}, 500)
},
// ...
)
- Tween the uniform object (
overlayMaterial.uniforms.uAlpha) targeting its value property, not the material.
- Wrap the intro in a short
setTimeout (about 500ms). Two reasons: the first render of a heavy scene freezes the machine for a moment, and the progress bar's own CSS transition (0.5s) has not finished when the loaded callback fires. Starting immediately makes the intro look jumpy.
Progress bar UX
- Build the bar in HTML/CSS on top of the canvas and animate it with
transform: scaleX(), which composites cheaply, rather than width:
.loading-bar
{
position: absolute;
top: 50%;
width: 100%;
height: 2px;
background: #ffffff;
transform: scaleX(0);
transform-origin: top left;
transition: transform 0.5s;
}
transform-origin: top left makes it fill from the left, otherwise scale grows from the center.
- The
transition: transform 0.5s smooths the per-asset jumps into a flowing fill for free, no JS tween needed.
- Update it from the progress callback:
const loadingBarElement = document.querySelector('.loading-bar')
// in progress callback
loadingBarElement.style.transform = `scaleX(${progressRatio})`
- Exit animation: shrink to the right by switching
transform-origin via a class. .loading-bar.ended { transform: scaleX(0); transform-origin: 100% 0; transition: transform 1.5s ease-in-out; } (no space between the selectors, both classes on one element).
- Gotcha: adding the
ended class alone does nothing because the inline scaleX(progressRatio) style from the progress callback outranks the class. Clear it with loadingBarElement.style.transform = '' at the same time.
Anchoring HTML points to 3D positions
- Pattern for interactive labels stuck to a model: absolutely positioned HTML elements, one per point of interest, repositioned every frame from a 3D anchor. HTML gives you
:hover, transitions, and real text for free.
- Data structure: an array pairing a
Vector3 with its DOM element:
const points = [
{
position: new THREE.Vector3(1.55, 0.3, - 0.6),
element: document.querySelector('.point-0')
}
]
- CSS starting point:
position: absolute; top: 50%; left: 50% because projected coordinates treat the screen center as 0. Hide the invisible tooltip text from the cursor with pointer-events: none or it can be hovered while transparent. Gate label visibility on a visible class (transform: scale(0,0) to scale(1,1) with a transition).
Project to screen space
- Per frame, per point: clone the anchor, project it through the camera, convert NDC to pixels:
for(const point of points) {
const screenPosition = point.position.clone()
screenPosition.project(camera)
const translateX = screenPosition.x * sizes.width * 0.5
const translateY = - screenPosition.y * sizes.height * 0.5
point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`
}
clone() is not optional: project() mutates the Vector3 in place, and without the clone your stored anchor position is destroyed on the first frame.
project(camera) yields -1 to +1 on each axis. Multiply by half the viewport size to get pixels from center.
- Negate y: CSS
translateY grows downward, Three.js y grows upward.
Occlusion: hide points behind geometry
- Cast a ray from the camera toward the point and compare distances.
setFromCamera happily accepts the projected Vector3 (only x and y are read), so the already-computed screenPosition doubles as the ray target:
const raycaster = new THREE.Raycaster()
// in the per-point loop, after projecting
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
if(intersects.length === 0) {
point.element.classList.add('visible')
}
else {
const intersectionDistance = intersects[0].distance
const pointDistance = point.position.distanceTo(camera.position)
if(intersectionDistance < pointDistance)
point.element.classList.remove('visible')
else
point.element.classList.add('visible')
}
- The
true second argument enables recursive testing through the whole scene graph, required when testing scene.children against a loaded model.
- Intersections come back sorted nearest first, so only
intersects[0] needs checking.
- The comparison is the whole algorithm: nothing hit means show; nearest hit closer than the point means an object blocks it, hide; nearest hit farther means the geometry is behind the point, show.
- No intersection at all also happens when the ray exits the model's silhouette, which is exactly when the point should show.
Wait for the scene before showing points
- Points positioned against a still-loading scene flash in the wrong state during the intro. Gate the whole loop on a readiness flag flipped after the intro has mostly played:
let sceneReady = false
// in the LoadingManager loaded callback
window.setTimeout(() => { sceneReady = true }, 2000)
// in tick
if(sceneReady) {
for(const point of points) { /* project + occlusion */ }
}
- Mixing HTML and WebGL is inherently costly: every frame touches the DOM with new inline transforms. Avoid the pattern when a texture or in-scene sprite would do, and when you use it, watch the frame rate on real devices.
- Easy win: only run projection and occlusion for points that can currently be visible, instead of all points every frame.
- The overlay approach keeps the intro entirely on the GPU (one uniform per frame); the loading bar keeps its animation on the CSS compositor (transform only). Both stay off the layout path.
Common mistakes
- Forgetting
transparent: true on the overlay ShaderMaterial, so uAlpha has no effect
- Using a
1 x 1 plane for the clip-space overlay, covering a quarter of the screen instead of all of it
- Testing the loader with cache enabled and no throttling, so progress code appears to work but was never seen running
- Adding the
ended class without clearing the inline transform, leaving the bar stuck at full width
- Starting the intro the instant loading finishes, colliding with first-render jank and the bar's unfinished transition
- Calling
project() on the stored anchor Vector3 instead of a clone, corrupting the position after one frame
- Forgetting to negate the projected y before writing
translateY
- Passing
scene.children to intersectObjects without the recursive true flag, so model meshes are never hit
- Hiding a point whenever anything intersects, without comparing
intersects[0].distance to point.position.distanceTo(camera.position)
- Leaving
pointer-events active on invisible tooltip text, making transparent elements hoverable
- Showing annotation points before the scene is ready, so they float over the loading overlay