WebGPU notes / 46
Resident Evil 2 Fixed-Camera Rendering with WebGPU and Rust
Resident Evil 2 (1998) was one of my favorite games of all time. During my school years, I spent countless hours drawing Leon, Claire, and scenes from the game. My schoolmates loved those drawings so much that they would often ask to keep them as souvenirs. For this example, I decided to re-create one of my favorite locations from the game: the R.P.D. Built with WebGPU, it combines three pre-rendered camera plates with a GPU skinned glTF character built by The Darkside Chronicles, classic tank controls, automatic camera cuts, and a live collision-space debugger. A small WebGPU experiment, but also a trip back to one of my favorite gaming memories.
- Fixed cameras
- 3
- Skin joints
- 52
- Character triangles
- 17,173
Compose a fixed-camera survival-horror scene in WebGPU
The previous ReSTIR GI article reconstructs indirect light from GPU reservoirs. This example changes scale and intent: it studies how a classic fixed-camera game can combine bundled 2D backgrounds with an interactive 3D character. The renderer picks one of three camera plates, stages Claire at a stable screen position, and cuts to a side view when her collision-space X coordinate crosses a camera boundary.
The Resident
Evil 2 sample commit introduced the complete example, its three WGSL shaders, bundled model and camera
assets, screenshot, tests, and gallery entry. The current Rust source runs through
sib::render and shares the repository's skinned glTF loader, asset loader, and virtual joystick.
Load three camera plates and one animated glTF scene
Startup loads three JPEG backgrounds as one batch and Claire's local glTF scene with its external binary and six
referenced images. Native builds read the same relative paths from disk; WebAssembly fetches them below the site's
assets directory before entering the render loop. Missing backgrounds, an empty drawable scene, or a
model without the required Idle, Slow Run, and Walking Backward clips
produce a descriptive error instead of a partially initialized frame.
The Claire asset contains 216 nodes, five meshes, five materials, one 52-joint skin, and three animation clips. Its five triangle primitives contribute 11,044 stored vertices and 51,519 indices, or 17,173 indexed triangles. Each 76-byte vertex carries position, normal, UV, RGB color, four joint indices, and four weights. The loader merges the primitives into shared buffers while retaining each index range, base-color image, sampler, and factor for five material draws.
Scroll sideways to see all table columns.
| Asset | Stored size | Encoded dimensions or contents | Runtime role |
|---|---|---|---|
camera_01.jpg |
539,238 bytes | 1672×941 JPEG | Front gate background |
camera_02.jpg |
586,675 bytes | 1672×941 JPEG | Security overlook background |
camera_03.jpg |
516,820 bytes | 1672×941 JPEG | East approach background |
scene.gltf + scene.bin |
2,732,859 bytes | 216 nodes; 5 meshes; 52 joints; 3 clips | Claire geometry, skeleton, and animation |
| Six glTF images | 793,178 bytes | Five base-color images plus one metallic-roughness image | Five base-color material bindings; metallic-roughness is not sampled |
| Vazirmatn font | 122,752 bytes | Embedded TrueType | GPU, FPS, camera, and attribution overlay |
The bundled character is “Claire Redfield - The Darkside Chronicles” by many-bees, licensed under CC BY 4.0. The canvas names many-bees and the CC BY 4.0 license; this article supplies the full asset-page links, and the repository includes the asset's license file.
The repository does not document a separate source or license for the three bundled R.P.D. camera plates. Their reuse terms are therefore unknown; verify their provenance and permission before redistributing or publishing those JPEGs outside this repository.
Crop each fixed-camera plate to cover the viewport
The background pipeline creates a full-screen triangle from vertex_index, so it needs no vertex
buffer. A 16-byte uniform stores the source image size and current surface size. The fragment shader compares
their aspect ratios and narrows the visible UV interval along one axis, applying cover-style cropping without
stretching the pre-rendered scene:
if (view_aspect > image_aspect) {
let visible_height = image_aspect / view_aspect;
uv.y = (uv.y - 0.5) * visible_height + 0.5;
} else {
let visible_width = view_aspect / image_aspect;
uv.x = (uv.x - 0.5) * visible_width + 0.5;
}
After sampling, an exponential curve 1 - exp(-rgb * 2.4) raises the plate's exposure. Resizing
rewrites all three aspect uniforms; it does not decode or upload the images again. Only the active camera's
texture is bound and drawn each frame.
Stage a 3D character over a 2D camera plate
The simulation keeps a collision-space position inside X from −4.15 to 4.15 and Z from −3.2 to 3.15. That position selects a camera, but it is not used directly as Claire's rendered translation. For each fixed view, Rust converts a desired normalized screen X and feet Y into view space at the character's current camera depth, transforms the result back to world space, and scales the mesh with depth and field of view. This lets movement drive camera cuts while keeping Claire legible against plates that do not contain true scene geometry.
The front camera maps collision-space X across roughly −0.72 to 0.72 NDC. Entering a side zone requires X beyond ±3.2; returning to the front requires crossing back inside ±2.7. That 0.5-unit hysteresis prevents rapid camera flicker near a boundary. In a side view the character enters near the opposite screen edge and moves toward an inner X of 0.10 as she approaches the world limit.
Scroll sideways to see all table columns.
| Camera | Eye | Target | Vertical FOV | Activation |
|---|---|---|---|---|
| Front gate | (5.8, 3.25, 7.4) | (0, 0.85, 0.35) | 46° | Central X zone |
| Security overlook | (−5.4, 6.8, 4.7) | (−0.7, 0.55, −0.15) | 49° | X < −3.2; exit at −2.7 |
| East approach | (7.1, 2.75, −0.7) | (0.2, 0.75, −1.1) | 44° | X > 3.2; exit at 2.7 |
Connect tank controls to three named animation clips
The shared joystick exposes a movement axis and a look axis. On desktop, W/S moves forward and backward, A/D turns, and the left/right arrow keys add turning through the look axis. Mouse or touch starts a virtual stick on the half of the canvas where the pointer begins: the left stick supplies movement and the right stick supplies look. Active sticks are drawn by a small alpha-blended GPU overlay.
Horizontal input changes yaw at 2.45 radians per second. Vertical input moves along Claire's facing vector at up
to 2.15 world units per second, with frame delta capped at 1/15 second. Forward selects Slow Run,
backward selects Walking Backward, and a 0.04 dead zone selects Idle. Locomotion clip
speed follows the vertical input magnitude while idle continues at normal speed. Changing between idle, forward,
and backward motion restarts the selected named clip; turning alone does not. All three clips loop through the
shared CPU animation sampler.
Skin, shade, and ground Claire
Rust evaluates the active glTF animation and writes a fixed 128-matrix joint palette every update; the first 52 matrices are used. The vertex shader forms one linear blend from four joint matrices, transforms the position, and applies the same blended transform to the normal. Five indexed draws reuse the merged vertex and index buffers while binding each primitive's base-color texture and factor.
The fragment shader discards alpha below 0.42 for hair cards, then combines a warm directional key, cool blue
fill, Blinn-style specular highlight, and rim term. It intentionally ignores the glTF metallic-roughness texture
and does not reproduce physically based material response. The character pipeline writes a single-sampled
Depth32Float attachment with LessEqual comparison.
A separate six-index quad supplies a soft contact shadow. Its fragment alpha falls from 0.42 at the center toward
zero at the ellipse edge, and standard alpha blending darkens the plate below Claire. Depth comparison is
Always with writes disabled, so this is a staged grounding cue rather than a light-space shadow map
or geometry-aware receiver.
Debug collision space independently from rendered staging
The collapsible egui panel can show Claire's animated render AABB, the clamped collision pivot, and a top-down map of the rectangular world limits. When bounding volumes are enabled, the example CPU-skins every vertex, builds an axis-aligned bound from the finite posed positions, projects its 12 edges through the active camera, and paints them over the scene. This is deliberately debug-only work; leaving bounding volumes hidden avoids the CPU pose extraction.
The cyan box describes the staged render mesh, while the yellow pivot and red blocking strips describe gameplay space. Keeping both views visible makes an otherwise subtle design decision explicit: the example simulates a small 2D navigation rectangle, then composes the actor separately for each fixed camera rather than colliding against reconstructed plate geometry.
Record the background, world, overlay, and GUI work
A frame uses four ordered render-pass groups. The first clears the surface and draws the active full-screen background. The second loads color, clears depth, draws the contact shadow, then draws the five character primitives. The third loads color for the text and virtual-stick geometry. The final egui pass loads color again for the debug window and projected debug shapes.
Scroll sideways to see all table columns.
| Order | Pass | Submission | Depth | Purpose |
|---|---|---|---|---|
| 1 | Fixed camera background | 3 procedural vertices | None | Cover-cropped active JPEG plate |
| 2 | World | 6 shadow indices + 51,519 character indices | Cleared Depth32Float |
Contact ellipse and five skinned material draws |
| 3 | Overlay | Text mesh + active joystick geometry | None | Attribution, GPU/FPS status, and controls |
| 4 | egui | Framework-managed draw data | None | Collision panel and optional projected bounds |
Run and extend the example
From a local checkout with Rust installed, run the native fixed-camera example:
cargo run --example residentevil2
For WebAssembly, install the wasm32-unknown-unknown target and the wasm-bindgen CLI
version matching Cargo.lock, then build and serve:
scripts/build-wasm.sh --release residentevil2
cargo run --bin serve
Open http://127.0.0.1:8080/residentevil2/ in a browser with WebGPU support. Use W/S to move and A/D
or the left/right arrow keys to turn, or drag virtual sticks with a pointer. The model, binary, material images,
and three camera plates must remain at their current relative asset paths.
This is a rendering and interaction study rather than a reconstructed R.P.D. level. The backgrounds have no depth, normals, collision meshes, or camera-calibration metadata. Claire cannot move behind foreground objects, receive plate lighting, cast shadows onto pre-rendered surfaces, or become occluded by the scene. The contact ellipse always overlays the background, and the fixed screen-staging rules are authored for these three views.
The glTF shader uses linear blend skinning and a compact artistic lighting model. There is no inverse-transpose normal matrix, PBR material evaluation, environment lighting, antialiasing, temporal accumulation, GPU animation, or animation blending. Collision is only a clamped rectangle; the debug AABB is visualized but not used for collision response.
Useful changes to try:
- Author camera calibration, depth, and foreground masks so Claire can pass behind objects in the plates.
- Replace the rectangular limits with navigation polygons and trigger volumes for camera cuts.
- Blend transitions between idle, run, and backward clips.
- Reconstruct approximate plate lighting and shadows, then compare the composite across all three cameras.