1 / , ← → to navigate, Esc to exit
Interactive Graphics, Final Project, Sapienza University of Rome

Moonlit Cave Diorama

Christian Carrillo 2017626  ·  Akshata Jain 2263069
A real-time WebGL scene built from scratch on three.js, no physics or game engine.

1Scene Overview

Development environment

The project is written in plain ES-module JavaScript on top of three.js r160 (WebGL 2), loaded through an HTML import map from unpkg, no bundler, build step, physics engine or game engine. The entire scene lives in a single main.js served as static files, so it runs from any static web server (e.g. python -m http.server) or GitHub Pages. The WebGLRenderer is created with antialias and stencil buffers, sRGB output, ACES filmic tone mapping and PCF soft shadow maps; localClippingEnabled is switched on for the diorama cut (Section 3.3). Besides three.js itself, the only libraries used are two of its addon modules, tween.js for eased transitions and lil-gui for the debug panel, listed in full in Section 4.

World layout

The world is built in a Z-up coordinate system (camera.up.set(0,0,1)), rather than the Y-up convention three.js uses by default. The diagram below is a top-down map of the world, drawn to the actual coordinates used in main.js.

y = 10y = 5y = 0 y = −5y = −10 x = −3x = 0x = 3 edge fog banks (beyond the water plane) OPEN MOONLIT WATER moon + halo entrance clipping plane (y = 4), the diorama cut CAVE (stencil-capped) FLEE_RADIUS = 4 (encounter trigger) river spline (Catmull-Rom) boat spline: in → river loop → out bat roost, 350 bats (x ∈ [−2.5, 2.5], y ∈ [−11, −8]) bats' flee route wall torches (raycast-snapped) fireflies drift near the mouth
Figure 1. Top-down map of the world, drawn to the real coordinates in main.js (1 world unit = 30 px vertically; the x-axis is stretched ×2.3 so the narrow cave reads clearly). The moon sits far beyond the map, positioned from GUI spherical coordinates.

the cave is an optimized OBJ/MTL mesh, normal-mapped, lit almost entirely by point-light torches; the diorama cut removes everything behind the entrance and fills the cross-section with a stencil-buffer cap (Section 3.3); the river follows a Catmull-Rom spline while a longer spline carries the boat in and out of the cave; the boat carries a skeletal, IK-rigged captain whose arms track the procedurally animated oars every frame; 350 rigged bats roost deep in the cave and react to the boat with a scripted encounter; the water is a single custom-shader mesh with waves, oar ripples, a boat wake, torch reflections, a moon glade and shoreline opacity blending; and fireflies plus layered fog banks add atmosphere at the edges.

Interior with boat
Figure 2. Interior: torch-lit river, boat and captain mid-stroke.
Cave mouth with fireflies
Figure 3. The cave mouth: torches, fireflies and returning bats.

How the project meets the course requirements

RequirementWhere it is satisfied
Hierarchical models
(at least one complex model)
The boat is a hierarchy of hull → oar bones → the parented, skeleton-rigged captain, whose shoulder/elbow/hand bone chains are driven by analytic two-bone IK exploiting exactly that structure (Section 3.6). Each of the 350 bats is an independently cloned skinned skeleton (Section 3.7); torches parent flame quads and point lights (Sections 3.2, 3.5).
Lights and textures
(≥ 1 light; textures of different kinds)
Ambient + hemisphere + directional fill, a moon directional light, and three flickering torch point lights with distance-selected shadow casting (Section 3.2). Textures: color/diffuse maps (cave MTL, moon, captain PBR), a normal map (cave_normal.png), a runtime canvas-generated halo sprite, and procedural shader "textures" for water, flames and fog (Sections 3.4, 3.5, 3.9).
User interaction Pointer-locked free-flight camera with keyboard movement, scroll-wheel speed and boost (Section 2), plus a live GUI of ~30 parameters, lights, colors, waves, animation speeds, the moon's position, and an on-screen HUD (Section 3.11).
Animations
(implemented in JavaScript, not imported)
No animation is imported anywhere. Models are loaded as static or rigged-but-unanimated meshes; every motion, boat spline-following and wave-rocking, procedural oar strokes, the captain's per-frame IK, bat steering and flapping, torch flicker, water swell, ripples and wake, fireflies, fog drift, is computed in JavaScript and GLSL written by the team (Sections 3.2–3.9).

2Controls

The scene starts behind a "Click to start" overlay that requests pointer lock; once locked, the mouse controls look direction and the overlay disappears. A short animated hint fades in on first lock and disappears as soon as the player actually moves the mouse.

InputAction
MouseLook around (pointer-locked, Z-up yaw/pitch)
W A S DMove forward / left / backward / right
SpaceAscend
ShiftDescend
Ctrl (held)Speed boost (×4)
Mouse wheelAdjust base flight speed (exponential scale)
HToggle the debug HUD and parameter GUI

Movement is smoothed with exponential damping toward a target velocity (SMOOTHING = 12).

Beyond navigation, the second interaction surface is the live parameter GUI toggled with H: it exposes roughly thirty scene parameters, torch brightness, water colors and reflectivity, wave amplitude, boat and oar speeds, bat flap speed, fog density, and the moon's position in the sky, so the viewer can reconfigure lighting, colors and animation behaviour while flying around the scene (detailed in Section 3.11). Resizing the browser window is also handled live, and the pointer lock can be released and re-acquired at any time with Esc / click.

3Technical Implementation

This section walks through every major rendering and animation system in the scene. All systems run on three.js primitives, every shader, animation and behaviour described below is hand-written in main.js.

3.1 Camera: a Z-up fly controller

three.js's built-in PointerLockControls assumes a Y-up world, so the project implements a small custom ZUpFlyControls class. Look direction is stored as separate yaw and pitch angles and composed as three quaternions: a fixed 90° base rotation that re-orients the camera's default −Z-forward basis onto the Z-up world, a yaw quaternion about the world Z axis, and a pitch quaternion about the camera's local X axis:

this._qYaw.setFromAxisAngle(this._Z, this._yaw);
this._qPitch.setFromAxisAngle(this._X, this._pitch);
this.camera.quaternion.copy(this._qYaw).multiply(this._qBase).multiply(this._qPitch);

Pitch is clamped just short of ±90° to avoid gimbal flip, and movement vectors are derived from the camera's local X column of its world matrix, crossed with world-up, so strafing stays level regardless of pitch.

3.2 Lighting

The base fill comes from a low-intensity AmbientLight and a HemisphereLight tinting the "sky" side blue-grey and the "ground" side warm brown, plus a faint directional "sky shaft" aimed into the cave mouth. All of the readable lighting, however, comes from two dynamic sources.

The moon

A textured sphere plus an additive-blended sprite halo, generated procedurally at runtime with a canvas radial gradient, avoiding an extra texture asset, are positioned from spherical coordinates (azimuth, elevation, distance), all exposed as GUI sliders:

const x = MOON_TARGET.x + d * ce * Math.sin(az);
const y = MOON_TARGET.y + d * ce * Math.cos(az);
const z = MOON_TARGET.z + d * Math.sin(el);
MOON_WORLD.set(x, y, z);
moonGroup.position.copy(MOON_WORLD);
moonLight.position.copy(MOON_WORLD);

Crucially, MOON_WORLD is the same THREE.Vector3 instance backing the water shader's uMoonPos uniform, so dragging the moon in the GUI instantly moves both the directional light and its specular reflection on the water (Section 3.4).

Figure 4. The moon, its halo sprite and its "glade" as the bat colony streams across it: the reflection is computed from the shared moon-position uniform through the same perturbed normal as every other specular term, so it stretches and glitters with the swell.

Wall torches

Three point lights are placed procedurally along the river curve, using the curve tangent to find an outward-facing wall normal, then raycast onto the loaded cave mesh so each torch snaps to the actual rock surface instead of a hand-picked coordinate:

_torchRay.set(origin, outward);
const hits = _torchRay.intersectObject(caveRoot, true);
if (hits.length) {
    torch.position.copy(hits[0].point).addScaledVector(outward, -0.12);
}

Each torch's intensity is modulated every frame by a flicker function combining two out-of-phase sine waves (a fast "wobble") with a slowly-drifting random "crackle" term, so no two torches flicker identically. To keep real-time shadows affordable, only the two torches nearest the camera cast shadows at any moment (MAX_ACTIVE_SHADOW_TORCHES = 2), re-evaluated every frame by distance.

3.3 The Diorama Cut: clipping planes and stencil capping

The signature visual, a cave that looks physically sliced open rather than merely clipped, is built from two features used together. First, every cave material carries a clippingPlanes entry (entranceCut) that discards fragments beyond the cave mouth. On its own this leaves a hollow, paper-thin shell. To make the cut read as solid rock, the classic "cap clipped geometry with the stencil buffer" technique is reproduced:

1, stencil marking (invisible) clip plane back faces +1 front faces −1 colorWrite: false 2, stencil result stencil ≠ 0 exactly where the plane slices solid volume 3, cap draw capMesh, NotEqualStencilFunc solid rock cross-section
Figure 5. The stencil-capping pipeline. For a closed mesh, incrementing on back faces and decrementing on front faces leaves a non-zero stencil value exactly where the clipping plane cuts through solid volume; a flat cap mesh drawn with NotEqualStencilFunc rasterises only there.
const backPass  = makeStencilPass(child, THREE.BackSide,  THREE.IncrementWrapStencilOp, 0);
const frontPass = makeStencilPass(child, THREE.FrontSide, THREE.DecrementWrapStencilOp, 1);
stencilPassGroup.add(backPass, frontPass);

A second, independent clipping plane (roofCutPlane) is applied only to the cap and stencil passes, so the solid cap does not extend up through the roof of the cave. The renderer is created with stencil: true and localClippingEnabled = true to make all of this possible, the technique costs an extra pair of invisible (colorWrite: false) draw calls per cave mesh.

The diorama cut seen from outside
Figure 6. The cut seen from outside: the pale cross-section is the stencil cap, without it, the camera would see straight into a hollow shell.

3.4 Water Shader

The water, both the interior river and the open moonlit surface, is a single THREE.Mesh driven by a hand-written ShaderMaterial. It combines a CPU-side height function (used for gameplay queries such as camera submersion and oar-tip depth) with a matching GPU-side displacement, plus several visual layers.

Base swell

Three sine waves of different amplitude, direction, frequency and speed are summed, both in JavaScript (waveHeight(), used to float the boat and detect submersion) and in the vertex shader, so the visible mesh always matches what physics queries assume. The two implementations are kept deliberately close in structure, same wave list, same sine summation, so gameplay queries and the visible surface never disagree, at the accepted cost of maintaining the wave stack in two places:

function waveHeight(x, y, t, ampScale = 1.0) {
    let h = 0.0;
    for (const w of WAVES) {
        const phase = (w.dir.x * x + w.dir.y * y) * w.freq + t * w.speed * w.freq;
        h += w.amp * ampScale * Math.sin(phase);
    }
    return h;
}

Oar ripples

Each oar blade's tip is found once at load time by scanning the oar mesh's vertices for the one farthest from the bone origin, then tracked every frame. When the tip crosses from above to below the local wave surface, a ripple is stamped into a ring buffer of up to 12 concurrent ripples, each carrying only an origin and a start time. The vertex shader turns each ripple into an expanding, decaying, gaussian-windowed ring wave:

hripple(p, t) = A · e−λt · e−w(‖p−o‖ − vt)² · sin( f‖p−o‖ − ωt )

with matching analytic partial derivatives computed alongside the height, so the ripple contributes correctly to the water's lighting normal, not just its geometry.

Boat wake & shading

A separate procedural displacement adds a hull trough, a bow bulge, two diverging Kelvin-wake arms and transverse stern waves behind the boat, all scaled by the boat's instantaneous speed, so a stationary boat leaves the water undisturbed. The fragment shader tone-maps manually (ACES filmic + linear-to-sRGB), evaluates per-torch diffuse against an "ombre" base colour (deep teal → bright cyan by depth into the cave), three specular lobes of increasing sharpness, and a Schlick-approximated Fresnel term blending in sky reflection at grazing angles. A caveMask fades torchlight to zero past the cave mouth so no torch light leaks onto the exterior water.

Boat rowing with wake and ripples
Figure 7. The water systems working together: swell, oar-strike ripples, torch specular lobes, and the boat floating on the same waveHeight() the shader displaces with.

Shoreline opacity blending

Before the main render, the scene is rendered once more (water, bats and fog hidden) into an off-screen WebGLRenderTarget carrying a half-resolution DepthTexture. In the main pass, the water fragment shader compares its own view-space depth against that texture at the same screen position; where the two are nearly equal the water is touching solid geometry, and the shader increases the water's opacity there (visible hugging the banks in Figure 7).

3.5 Torch Flames

Each torch carries a billboard quad, rotated in JavaScript to face the camera, textured procedurally by a fragment shader rather than a sprite sheet. The shader layers four octaves of value noise, domain-warped by an extra low-frequency noise sample so the flame reads as licking, curling tongues rather than a scrolling stripe pattern; a teardrop distance field pinches the flame to a point so tongues can visually detach; and an independent "core" field keeps a white-hot base near the wood.

Procedural torch flame
Figure 8. The procedural flame shader. The same flicker value that perturbs the shader's brightness (uFlick) also scales the torch's PointLight intensity, so the visible flame and the light it casts always pulse together.

3.6 Boat, Oars and the IK-Rigged Captain

The boat (an imported FBX model) follows its own Catmull-Rom spline extending beyond the river at both ends, so it can enter and leave the diorama; its progress is rescaled by the ratio of the two curves' arc lengths (BOAT_SPEED_SCALE). Every frame the boat samples the wave height at four offset points, bow, stern, port, starboard, and derives pitch and roll from the slope by finite differences, so it visibly rocks with the swell it floats on.

The Oar_L / Oar_R bones are located in the boat's skeleton by name and driven with two composed rotations: a sweep angle about the boat's local up axis and a lift angle about the oar's outboard axis, both sinusoidal functions of a shared rowing-time accumulator. There is no baked rowing animation, the whole stroke is procedural, which lets the bat "panic" encounter (Section 3.7) speed the oars up independently of the boat's own speed.

Posing the captain into the boat

The captain is a separate rigged glTF character (Mixamo-style skeleton) that ships in a standing T-pose with no animation clips; the seated pose is built in code, once, right after loading. First the model is scaled so its bounding-box height matches CAPT_HEIGHT. Then the leg bones are found by name and bent directly with quaternion rotations: each thigh (mixamorig:LeftUpLeg / RightUpLeg) is rotated CAPT_THIGH_BEND (≈83°) about the sideways axis to raise it into a sitting position, plus a mirrored CAPT_LEG_SPREAD yaw so the knees part naturally around the hull, and each knee (mixamorig:LeftLeg / RightLeg) is bent back by CAPT_KNEE_BEND so the feet tuck under the seat:

const sitThigh = (boneName, spreadSign) => {
    const b = findBone(model, boneName);
    b.quaternion
        .multiply(_captQ.setFromAxisAngle(CAPT_ARM_AXIS,    CAPT_THIGH_BEND))
        .multiply(_captQ.setFromAxisAngle(CAPT_SPREAD_AXIS, CAPT_LEG_SPREAD * spreadSign));
};
sitThigh('mixamorig:LeftUpLeg',  -1);
sitThigh('mixamorig:RightUpLeg', +1);

After the bends, the model is translated so that its hip bone's world position, not its original origin, sits at the local zero, which makes the pelvis the anchor point of the whole character. The result is parented into a captGroup inside the boat hierarchy, rotated 180° to face the stern (rowers face backwards), and placed on the thwart at CAPT_SEAT; the exposed seatHeight GUI slider moves this group live. Because the pose is applied to bone quaternions rather than baked vertices, the arms remain fully free for the IK described next, and the rest pose of every arm bone is recorded at this moment as the IK's reference frame.

loaded rest pose, no animation clips sitThigh() · sitKnee() quaternion bends, once at load CAPT_SEAT (thwart) arm bones untouched: rest pose recorded here, driven by IK (Fig. 10) ≈ 83° ≈ −86° hips → local origin (anchor) re-centred on the hips, parented into the boat thigh +CAPT_THIGH_BEND ≈ 83° · knee CAPT_KNEE_BEND ≈ −86° · ±CAPT_LEG_SPREAD parts the knees (mirrored L/R) applied once to the bone quaternions at load — no baked pose or animation is imported with the model
Figure 9. The load-time seated pose: the T-posed skeleton's thigh and knee bones are bent by fixed quaternion rotations, the model is re-centred on its hip bone, then parented into the boat facing the stern. The arm bones are deliberately left untouched for the per-frame IK (Figure 10).

The captain: analytic two-bone IK

With the captain seated and parented into the boat, the arms are animated separately. Rather than hand-authoring a rowing clip, a classic two-bone (shoulder/elbow) analytic IK chain is solved every frame, aiming each hand at that frame's oar-handle position:

d = ‖target − shoulder‖ L1 (upper arm) L2 (forearm) α shoulder elbow hand → oar handle α = acos( (L1² + d² − L2²) / (2 · L1 · d) ) the elbow is placed L1 along the shoulder→target axis, then rotated by α about a perpendicular reference axis
Figure 10. Two-bone analytic IK via the law of cosines, no iteration, no solver library.
const cosA = clamp((L1*L1 + d*d - L2*L2) / (2*L1*d), -1, 1);
const a = Math.acos(cosA);
// elbow position = arm-start + L1 along the shoulder-target axis,
// rotated by angle 'a' around a perpendicular reference axis

Because the target is read directly from the oar's current handle position, the captain's hands remain somewhat glued (the best we could do) to the oars under the combination of the tunable oarSpeed, oarAmplitude and oarLift GUI parameters, with no animation re-authoring. A final gripHand() step orients each hand bone to align with the forearm and applies a configurable roll so the palm faces the oar shaft.

Figure 11. The IK solution live: both hands track the sinusoidally sweeping oar handles every frame while the boat pitches and rolls on the swell beneath.

3.7 Bat Colony: state machine and steering

350 bats share one skinned mesh source, loaded once, then cloned per instance with SkeletonUtils.clone so each has an independent skeleton, roosting in a bounding box near the back of the cave. Each bat is a small finite-state machine driven by a simplified steering model: every frame a bat accumulates acceleration from (a) a pull toward its roost point or exit target, (b) random wander noise, and, while fleeing, (c) a repulsion term scaled by the boat's proximity, integrated with velocity damping and a hard speed clamp.

Per-bat FSM roost flee gone return boat < FLEE_RADIUS exited cave recall signal Scripted encounter FSM (scene-level) cruising frozen panicking batsAwayWait batsReturning captainAway… boat < 2.6 of roost
Figure 12. The two layered state machines: each bat runs its own steering FSM (top), while a scene-level encounter script (bottom) freezes the boat, drives it backward at panicBoatMult speed with faster oar strokes, and re-arms once the boat is far enough away.
Figure 13. The encounter firing: the colony erupts from the roost and streams toward the exit while the frozen boat waits below, then the captain rows backward in panic.

Naively updating skeletal poses and steering for every bat every frame was the single largest performance risk in the project. Rather than reducing bat count or fidelity, a level-of-detail rule skips the steering update (not the rendering) for roosting bats far from the camera on most frames (LOD_SKIP_FRAMES), based on squared camera distance, cheap to compute and invisible to the player, since idle roosting motion is small and slow, so the colony keeps looking full-density from any distance.

3.8 Fireflies

Fireflies are a single THREE.Points cloud whose wandering motion is computed entirely on the GPU: each point carries a random phase and a 3-component seed attribute, and the vertex shader offsets its position with a small sum of sine waves driven by those seeds and the global time uniform, the CPU does no per-particle work at all. The fragment shader draws a soft round glow (a smoothstepped radial falloff) whose brightness pulses with the same phase attribute, composited with additive blending.

Figure 14. GPU-driven fireflies drifting at the cave mouth.

3.9 Edge Fog and Underwater Submersion

Along the far edges of the world, banks of translucent, shader-textured planes, front-facing sheets, cross-facing sheets, and flat "ceiling" sheets, stand in for volumetric fog without a volumetric renderer. Their shared fragment shader layers warped fractal noise into rolling, cauliflower-like billow shapes, fakes self-shadowing by comparing two vertically-offset noise samples, and cross-fades each sheet's opacity by how face-on the camera views it, so the illusion doesn't collapse into visibly flat planes. This combines with FogExp2, whose colour and density are interpolated every frame between a normal, an underwater, and a "near the world edge" state.

Camera submersion is computed by comparing the camera's height against the local wave height directly beneath it, and drives a DOM overlay (blur + saturation via CSS backdrop-filter, plus a tint) so going underwater is felt as a full-screen effect, not just a change in the 3D scene.

Fog banks over the moonlit water at the world edge
Figure 15. The edge fog in place: layered noise-textured fog banks swallow the horizon under the moon, blending with FogExp2 so the world fades out instead of ending at visible geometry.

3.10 Depth Pre-pass and Frame Structure

Because three.js materials cannot normally sample the depth of other objects, the shoreline opacity blending (Section 3.4) requires a manual two-pass frame:

1. depth pre-pass water/bats/fog hidden ½-res DepthTexture 2. stencil passes back +1 / front −1 colorWrite: false 3. main render cap + water sample the depth texture 4. TWEEN + HUD update One animation frame:
Figure 16. Frame structure. The extra pass stays cheap: fewer objects, half resolution, while giving the water shader an accurate picture of nearby solid geometry for blending.

3.11 HUD, Debug GUI and Tuning

A lil-gui panel (hidden by default, toggled with H) exposes roughly thirty live parameters spanning boat speed, oar motion, IK arm reach/elbow angle/hand roll, seat height, bat flap speed, torch brightness and transform, flame appearance, wave amplitude, water reflectivity/opacity/sparkle/glint/flow, boat wake strength, edge fog density, and the moon's azimuth/elevation/distance/glow/halo/reflection, effectively every constant in Section 3 was tuned live rather than guessed at in code. A lightweight on-screen HUD reports camera position, look vector, speed multiplier, FPS, draw calls and triangle count.

4Libraries, Tools, Models and Credits

Everything used in the project that was not developed by the team is listed here. All scene behaviour, shaders and animation are original code.

Libraries

LibraryVersion / sourceUsed for
three.jsr160, unpkg via import mapWebGL rendering, scene graph, math
three addons: OBJLoader, MTLLoader, GLTFLoader, FBXLoaderbundled with r160Loading the cave, captain and boat models (geometry and materials only, no animation clips)
SkeletonUtilsthree addonCloning the rigged bat 350×
tween.jsthree addon moduleEased transitions (fog/submersion states, encounter timing)
lil-guithree addon moduleThe live debug/tuning panel (Section 3.11)

No physics engine, game engine, animation library or build tool is used; everything else in the project is hand-written JavaScript and GLSL in main.js.

Models and textures

External assets are loaded at runtime from the models/ directory. They are used as static or rigged-but-unanimated meshes: no bundled animation is imported, all motion is implemented by the team (see the requirements table in Section 1).

AssetFileSource
Cave environment (OBJ + MTL + normal map)CaveOptimizedobj.obj/.mtl, cave_normal.pngFab
RowboatOld_Rowboat_low.fbxSketchfab
Batbat.glbSketchfab
Captain (rigged character)captain/captain-clark.gltfSketchfab
Wall torchtorch/wall_torch.glbSketchfab
Moon texturemoon.png

5Conclusion

Moonlit Cave Diorama combines a range of real-time rendering techniques, custom vertex/fragment shaders for water, fire and fog, stencil-buffer geometry capping, a depth-texture pre-pass, analytic inverse kinematics, and lightweight steering-based character AI,into a single self-contained scene built directly on three.js primitives, with almost every visual parameter exposed and tuned through a live debug GUI.

The result is a free-flying, "living diorama" rather than a fixed camera path: the boat, its crew and the bat colony all run on independent clocks and state machines, so the scene keeps behaving correctly no matter where or when the player chooses to look.

Moonlit water
Figure 17. The moon framed by the cave opening: moonlight, swell, and fog.

Christian Carrillo · Akshata Jain, Interactive Graphics, Sapienza University of Rome, 2026.
All figures captured live from the running scene. Animated figures are GIFs in report_media/.
AI was used as an aid in the creation of this project.