Loading WebGPU example...
More info
Back to the demo ↑

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.

Claire Redfield standing before the rainy R.P.D. front gate in a WebGPU fixed-camera scene with a small status and model-credit overlay.
The front-gate capture shows Claire composited over the rainy R.P.D. entrance, with the GPU/FPS, camera, and model-credit text visible in the upper-left. The virtual sticks are inactive, and the current collapsible collision window is not present in this 1280×720 image.

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.

Resident Evil 2 example runtime assets
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.

Three fixed perspective cameras
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.

Resident Evil 2 frame composition
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.