hud.ts
examples/quake/hud.ts · raw · open the example · all examples · site home
Exports: FrameModeViewRectQuakeHudFrameSplatsLoadingPanel
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370
// The 2D layer over the splat view, all from the sim: the software frame as
// a transparent overlay (the worker gives it alpha from the overlay mask, so
// the console, menus, status bar, messages and intermission pages show while
// the 3D view area is see-through) drawn as one splat per pixel
// (FrameSplats) or, for comparison, blitted to a canvas; the palette blend
// (damage, powerups, underwater) as a tint, the control hint, and the
// LoadingPanel shown while a level's splats are not on screen yet.
import * as THREE from "three";
import type { WebGPURenderer } from "three/webgpu";
import { ArrayData, GpuArrayReader, GpuArrays, GpuSplats, SplatCollection, SplatRenderer, TSL, defineBundle, type SplatGenerator, type SplatObject } from "@sparkjsdev/radix";
import type { QuakeGame } from "./game.js";
import { readBlend } from "./sim/QuakeSim.js";
const { float, uint, uniform, vec3, vec4 } = TSL;
// Vanilla 4:3 display of the 320x240 frame
const DISPLAY_ASPECT = 4 / 3;
// Frame splat radius in pixels (the bake's texel splat scale)
const PIXEL_RADIUS = 0.7;
// The frame screen's distance ahead of its view (meters)
const FRAME_DISTANCE = 1;
export type FrameMode = "splats" | "canvas" | "off";
// The engine's 3D view inside the frame (pixels) and its horizontal fov
export interface ViewRect {
x: number;
y: number;
width: number;
height: number;
fovX: number;
}
export class QuakeHud {
readonly root: HTMLDivElement;
readonly frameSplats: FrameSplats;
// Show the view model
showWeapon = true;
private readonly game: QuakeGame;
private readonly frame: HTMLCanvasElement;
private readonly frameContext: CanvasRenderingContext2D;
private readonly overlay: HTMLCanvasElement;
private readonly overlayContext: CanvasRenderingContext2D;
private readonly tint: HTMLDivElement;
private readonly hint: HTMLDivElement;
private hintText?: string;
private drawnVersion = -1;
private mode: FrameMode = "splats";
private vanillaFrame = false;
constructor(game: QuakeGame, renderer: WebGPURenderer, sortMode: "gpu" | "cpu") {
this.game = game;
this.frameSplats = new FrameSplats(renderer, sortMode);
this.root = document.createElement("div");
this.root.style.cssText = "position:fixed;inset:0;pointer-events:none;";
document.body.appendChild(this.root);
this.frame = document.createElement("canvas");
this.frame.width = game.width;
this.frame.height = game.height;
this.frameContext = this.frame.getContext("2d")!;
this.tint = document.createElement("div");
this.tint.style.cssText = "position:absolute;inset:0;background:transparent;";
this.root.appendChild(this.tint);
this.overlay = document.createElement("canvas");
this.overlay.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;image-rendering:pixelated;";
this.overlayContext = this.overlay.getContext("2d")!;
this.root.appendChild(this.overlay);
this.hint = document.createElement("div");
this.hint.style.cssText = "position:absolute;left:50%;bottom:22%;transform:translateX(-50%);padding:6px 12px;"
+ "background:rgba(0,0,0,0.6);border-radius:3px;color:white;font:15px monospace;text-shadow:0 1px 2px black;white-space:pre;";
this.root.appendChild(this.hint);
addEventListener("resize", () => this.resize());
this.resize();
}
// How the software overlay is shown: as splats, blitted to a canvas, or not
get frameMode() {
return this.mode;
}
// Redraws the current frame right away, so the switch shows while paused
set frameMode(mode: FrameMode) {
this.mode = mode;
this.drawnVersion = -1;
}
// Debug: the whole software frame opaque (the vanilla view) for comparison
get vanilla() {
return this.vanillaFrame;
}
set vanilla(on: boolean) {
this.vanillaFrame = on;
this.game.setOpaqueFrame(on);
}
private updateHint() {
const { game } = this;
let text = "";
if (game.holdPaused) {
text = "";
} else if (!game.started) {
text = "Click to capture the mouse (enables sound) \\: pause / free fly";
} else if (!game.mouseCaptured) {
text = game.captureRetry
? "the browser needs a moment after Esc; click again to capture the mouse"
: game.userPaused
? "PAUSED - free fly: WASD, shift run, ctrl slow \\: play click to capture the mouse"
: game.menuActive
? "Quake menu: arrows/Enter, Esc closes - click to capture the mouse"
: game.consoleActive
? "Quake console: ` closes - click to capture the mouse"
: "click to capture the mouse `: console \\: pause / free fly";
} else if (game.userPaused) {
text = "PAUSED - free fly: WASD + mouse, shift run, ctrl slow \\: play Esc: release the mouse";
}
if (text !== this.hintText) {
this.hintText = text;
this.hint.textContent = text;
this.hint.style.display = text ? "block" : "none";
}
}
private resize() {
this.overlay.width = innerWidth;
this.overlay.height = innerHeight;
this.drawnVersion = -1;
}
// Per frame, after the game update. `frozen` is the camera pose following
// ended at (free fly: the screen stays there, in the world), else the
// screen is drawn over the world from the camera
update(camera: THREE.PerspectiveCamera, frozen: THREE.Matrix4 | undefined, rect: ViewRect) {
const { game } = this;
if (game.frame && game.frameVersion !== this.drawnVersion) {
this.drawnVersion = game.frameVersion;
if (this.frameMode === "splats") {
this.frameSplats.update(game.frame);
}
this.blit(game.frame);
}
this.frameSplats.place(camera, frozen, rect, this.frameMode === "splats" && !!game.frame);
this.updateTint();
this.updateHint();
}
private blit(frame: ImageData) {
const { overlayContext: context, overlay } = this;
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, overlay.width, overlay.height);
if (this.frameMode !== "canvas") {
return;
}
this.frameContext.putImageData(frame, 0, 0);
let width = overlay.width;
let height = width / DISPLAY_ASPECT;
if (height > overlay.height) {
height = overlay.height;
width = height * DISPLAY_ASPECT;
}
context.drawImage(this.frame, 0, 0, frame.width, frame.height, (overlay.width - width) / 2, (overlay.height - height) / 2, width, height);
}
// V_UpdatePalette's color shifts as one tint over the 3D view (the 2D
// overlay pixels already come palette-shifted from the software renderer)
private updateTint() {
let css = "transparent";
if (this.game.inLevel && !this.vanillaFrame) {
const [r, g, b, a] = readBlend(this.game.curr);
if (a > 0.002) {
css = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a.toFixed(3)})`;
}
}
if (this.tint.style.background !== css) {
this.tint.style.background = css;
}
}
dispose() {
this.frameSplats.dispose();
this.root.remove();
}
}
const PIXELS = defineBundle({ rgba: "uint8x4" });
interface FrameData {
pixels: GpuArrays;
width: number;
height: number;
}
// Pixel i of a width x height grid at (col + 0.5, height - row - 0.5, 0) in
// pixel units, colored by the frame (alpha 0 where the 3D view shows)
function frameGenerator(): SplatGenerator<FrameData> {
const reader = new GpuArrayReader(PIXELS);
const width = uniform(320, "uint");
const height = uniform(240, "uint");
return {
generateSplat: (i) => {
const index = uint(i);
const col = float(index.mod(width));
const row = float(index.div(width));
const rgba = vec4(reader.load("rgba", index)).div(255.0).toVar();
const center = vec3(col.add(0.5), float(height).sub(row).sub(0.5), 0.0);
return GpuSplats.Splat(center, vec3(PIXEL_RADIUS, PIXEL_RADIUS, PIXEL_RADIUS * 0.1), vec4(0, 0, 0, 1), rgba.rgb, rgba.a, index);
},
bind: (data) => {
reader.bind(data.pixels, "pixels");
width.value = data.width;
height.value = data.height;
},
};
}
// The software frame as splats: one per pixel on a fixed grid, placed as a
// screen FRAME_DISTANCE ahead of a view and sized so that pixel columns span
// the engine's fov across the view rect, centered on its optical axis. Seen
// through the matching camera projection (main.ts) it covers exactly the
// letterboxed 4:3 frame at any window size. While following it is drawn by
// its own renderer (`splats`) from `camera`, a camera at the origin with
// the main camera's projection, over the world; in free fly `worldObject`
// is the same screen where the player was, for the world's renderer to draw
// (sorted and occluded with the world). A new frame only uploads its pixels.
export class FrameSplats {
readonly splats: SplatRenderer;
readonly scene = new THREE.Scene();
readonly camera = new THREE.PerspectiveCamera();
// The screen in the world (free fly), else undefined
worldObject?: SplatObject<FrameData>;
private readonly generator = frameGenerator();
private readonly data = new ArrayData({ count: 320 * 240 }).addBundle("pixels", PIXELS);
private readonly pixels: GpuArrays;
private readonly matrixWorld = new THREE.Matrix4();
private object?: SplatObject<FrameData>;
private width = 0;
private height = 0;
constructor(renderer: WebGPURenderer, sortMode: "gpu" | "cpu") {
this.pixels = GpuArrays.fromData(renderer, this.data);
// One fixed viewpoint: on the CPU sort, sorted once and kept
this.splats = new SplatRenderer({ renderer, sortMode, viewKey: () => "hud" });
this.scene.add(this.splats);
this.camera.matrixAutoUpdate = false;
}
// New software frame: its RGBA
update(frame: ImageData) {
if (frame.width !== this.width || frame.height !== this.height) {
this.width = frame.width;
this.height = frame.height;
this.data.setCapacity(frame.width * frame.height);
this.object = SplatCollection.object({
generator: this.generator, data: { pixels: this.pixels, width: frame.width, height: frame.height }, count: frame.width * frame.height, matrixWorld: this.matrixWorld,
});
}
this.data.count = frame.width * frame.height;
this.data.bundles.pixels.channels.rgba.data.set(frame.data);
this.pixels.upload(this.data);
}
// Places the screen ahead of the camera, or of `frozen` in the world;
// hidden unless `show`
place(camera: THREE.PerspectiveCamera, frozen: THREE.Matrix4 | undefined, rect: ViewRect, show: boolean) {
const object = show ? this.object : undefined;
const pixel = (2 * FRAME_DISTANCE * Math.tan(THREE.MathUtils.degToRad(rect.fovX / 2))) / rect.width;
const cx = rect.x + rect.width / 2;
const cy = this.height - (rect.y + rect.height / 2);
this.matrixWorld.copy(frozen ?? IDENTITY)
.multiply(offset.makeTranslation(0, 0, -FRAME_DISTANCE))
.multiply(scale.makeScale(pixel, pixel, pixel))
.multiply(center.makeTranslation(-cx, -cy, 0));
this.worldObject = frozen ? object : undefined;
const collection = !frozen && object ? [object] : [];
if (collection[0] !== this.splats.collection.objects[0]?.object || collection.length !== this.splats.collection.objects.length) {
this.splats.collection = new SplatCollection(collection);
}
this.camera.projectionMatrix.copy(camera.projectionMatrix);
this.camera.projectionMatrixInverse.copy(camera.projectionMatrixInverse);
}
// Over the world (following only): color kept
render(renderer: WebGPURenderer) {
if (this.splats.collection.count === 0) {
return;
}
const clear = renderer.autoClearColor;
renderer.autoClearColor = false;
renderer.render(this.scene, this.camera);
renderer.autoClearColor = clear;
}
dispose() {
this.splats.dispose();
this.pixels.dispose();
}
}
const IDENTITY = new THREE.Matrix4();
const offset = new THREE.Matrix4();
const scale = new THREE.Matrix4();
const center = new THREE.Matrix4();
// Centered progress panel for the phases where nothing (or only part of the
// world) can be shown yet: boot, model atlas, scene loading, the first LoD
// selection
export class LoadingPanel {
readonly root: HTMLDivElement;
private readonly title: HTMLDivElement;
private readonly detail: HTMLDivElement;
private readonly bar: HTMLDivElement;
private readonly fill: HTMLDivElement;
visible = false;
constructor() {
this.root = document.createElement("div");
this.root.style.cssText = "position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);min-width:340px;max-width:80vw;"
+ "padding:14px 18px;background:rgba(0,0,0,0.75);border:1px solid rgba(255,255,255,0.25);border-radius:4px;"
+ "color:white;font:14px monospace;text-shadow:0 1px 2px black;pointer-events:none;display:none;z-index:10;";
this.title = document.createElement("div");
this.title.style.cssText = "font-size:16px;margin-bottom:8px;";
this.detail = document.createElement("div");
this.detail.style.cssText = "opacity:0.85;margin-bottom:8px;white-space:pre-wrap;";
this.bar = document.createElement("div");
this.bar.style.cssText = "height:8px;background:rgba(255,255,255,0.15);border-radius:2px;overflow:hidden;";
this.fill = document.createElement("div");
this.fill.style.cssText = "height:100%;width:0%;background:#a63;transition:width 0.15s linear;";
this.bar.appendChild(this.fill);
this.root.append(this.title, this.detail, this.bar);
document.body.appendChild(this.root);
}
show(title: string) {
this.title.textContent = title;
this.detail.textContent = "";
this.detail.style.color = "";
this.bar.style.display = "block";
this.fill.style.width = "0%";
this.root.style.display = "block";
this.visible = true;
}
step(detail: string, fraction?: number) {
if (!this.visible) {
return;
}
this.detail.textContent = detail;
if (fraction !== undefined) {
this.fill.style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`;
}
}
fail(message: string) {
this.root.style.display = "block";
this.visible = true;
this.detail.textContent = message;
this.detail.style.color = "#f66";
this.bar.style.display = "none";
}
hide() {
this.root.style.display = "none";
this.visible = false;
}
}