@waica/engine 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +43 -0
  2. package/dist/assets/asset-loader.d.ts +1 -1
  3. package/dist/assets/asset-loader.js +1 -1
  4. package/dist/assets/texture-backend.d.ts +1 -1
  5. package/dist/assets/texture-backend.js +1 -1
  6. package/dist/camera.js +1 -1
  7. package/dist/components/animated-sprite.d.ts +6 -0
  8. package/dist/components/animated-sprite.js +16 -2
  9. package/dist/components/light.d.ts +68 -0
  10. package/dist/components/light.js +59 -0
  11. package/dist/components/particle-emitter.d.ts +6 -0
  12. package/dist/components/particle-emitter.js +13 -0
  13. package/dist/components/sprite.d.ts +6 -0
  14. package/dist/components/sprite.js +16 -2
  15. package/dist/components/tilemap.js +9 -1
  16. package/dist/entity.d.ts +1 -1
  17. package/dist/entity.js +1 -1
  18. package/dist/frame-quad.d.ts +21 -0
  19. package/dist/frame-quad.js +50 -0
  20. package/dist/game.d.ts +28 -3
  21. package/dist/game.js +79 -52
  22. package/dist/index.d.ts +14 -3
  23. package/dist/index.js +9 -1
  24. package/dist/light-field.d.ts +78 -0
  25. package/dist/light-field.js +145 -0
  26. package/dist/light-map-material.d.ts +40 -0
  27. package/dist/light-map-material.js +203 -0
  28. package/dist/light-map.d.ts +46 -0
  29. package/dist/light-map.js +132 -0
  30. package/dist/lit-frame.d.ts +48 -0
  31. package/dist/lit-frame.js +167 -0
  32. package/dist/occluder-grid.d.ts +19 -0
  33. package/dist/occluder-grid.js +50 -0
  34. package/dist/particle-batch.d.ts +1 -1
  35. package/dist/particle-batch.js +7 -3
  36. package/dist/particle-sort-bounds.d.ts +1 -1
  37. package/dist/particle-sort-bounds.js +1 -1
  38. package/dist/pointer.js +1 -1
  39. package/dist/post-effects.d.ts +33 -0
  40. package/dist/post-effects.js +48 -0
  41. package/dist/post-pass.d.ts +23 -0
  42. package/dist/post-pass.js +93 -0
  43. package/dist/render-layers.d.ts +18 -0
  44. package/dist/render-layers.js +19 -0
  45. package/dist/render-output.d.ts +34 -0
  46. package/dist/render-output.js +60 -0
  47. package/dist/render-readiness.d.ts +37 -0
  48. package/dist/render-readiness.js +61 -0
  49. package/dist/render-sort.d.ts +14 -0
  50. package/dist/render-sort.js +37 -0
  51. package/dist/runtime-bridge.d.ts +18 -2
  52. package/dist/runtime-bridge.js +7 -1
  53. package/dist/runtime-inspection.d.ts +5 -0
  54. package/dist/runtime-inspection.js +5 -1
  55. package/dist/runtime-lighting-snapshot.d.ts +41 -0
  56. package/dist/runtime-lighting-snapshot.js +46 -0
  57. package/dist/scene-lighting.d.ts +52 -0
  58. package/dist/scene-lighting.js +107 -0
  59. package/dist/scene-render-options.d.ts +51 -0
  60. package/dist/scene-render-options.js +126 -0
  61. package/dist/scene.d.ts +29 -0
  62. package/dist/sprite-batch.d.ts +3 -3
  63. package/dist/sprite-batch.js +45 -29
  64. package/dist/sprite-batches.d.ts +2 -2
  65. package/dist/sprite-batches.js +1 -1
  66. package/dist/sprite-draw-collector.d.ts +3 -3
  67. package/dist/sprite-draw-collector.js +3 -3
  68. package/package.json +1 -1
package/dist/game.js CHANGED
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  import { gameViewport } from './anchored-pieces.js';
3
3
  import { AssetLoader } from './assets/asset-loader.js';
4
4
  import { AudioSubsystem } from './audio/audio-subsystem.js';
@@ -12,12 +12,18 @@ import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedT
12
12
  import { advanceGameTime, GameTime } from './game-time.js';
13
13
  import { Input } from './input.js';
14
14
  import { Pointer } from './pointer.js';
15
+ import { GamePost } from './post-effects.js';
15
16
  import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
16
17
  import { RuntimeInspector } from './runtime-inspection.js';
18
+ import { FrameComposer } from './lit-frame.js';
19
+ import { EMISSIVE_LAYER } from './render-layers.js';
17
20
  import { projectIsometric, unprojectIsometric } from './projection.js';
18
- import { isYSortBatchParticipant, isYSortParticipant, ySortZ, } from './render-sort.js';
21
+ import { canvasBackground, drawStraightToCanvas, mapUvPerVertex } from './render-output.js';
22
+ import { RenderReadiness } from './render-readiness.js';
23
+ import { applyYSort } from './render-sort.js';
19
24
  import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
20
25
  import { sceneDrainsOf } from './scene-drains.js';
26
+ import { GameLighting } from './scene-lighting.js';
21
27
  import { createSpriteBatches } from './sprite-batches.js';
22
28
  import { createSpatialQuery } from './spatial-query.js';
23
29
  import { Stats } from './stats.js';
@@ -62,6 +68,10 @@ export class Game {
62
68
  cameraEffects;
63
69
  /** Simulated scheduling: `after`, `every`, `tween`, `now`. See ADR 0017. */
64
70
  time = new GameTime();
71
+ /** The live scene's Ambient Light and Lights (ADR 0026); it dies with its scene. */
72
+ lighting = new GameLighting();
73
+ /** The live scene's Post Effects (vignette, color grade); they die with their scene. */
74
+ post = new GamePost();
65
75
  /** Registry retained by loadScene for runtime prefab spawning. */
66
76
  registry = null;
67
77
  paramOverrides = {};
@@ -70,11 +80,14 @@ export class Game {
70
80
  * or collisions — the editor's edit mode.
71
81
  */
72
82
  simulate = true;
73
- // TODO(H1): migrate to WebGPURenderer (three/webgpu) with automatic WebGL2 fallback.
83
+ /** WebGPU when the browser offers it, else three's WebGL2 fallback (ADR 0025). */
74
84
  renderer;
85
+ readiness;
75
86
  resizeObserver;
76
87
  /** Scene-scoped Sprite Batches (ADR 0024); `render.batch: false` opts a scene out. */
77
88
  spriteBatches;
89
+ /** Off, lit or through the Post Effects: how each frame is drawn (ADR 0026). */
90
+ frame;
78
91
  updateFns = new Set();
79
92
  invalidUpdateCompositions = new WeakMap();
80
93
  /**
@@ -105,6 +118,8 @@ export class Game {
105
118
  liveSceneName = null;
106
119
  /** True for the whole extent of a runFrame() call, incl. its tail. */
107
120
  insideFrame = false;
121
+ /** Set by dispose(): a disposed Game never starts again nor reports its renderer (review #3). */
122
+ disposed = false;
108
123
  /** A loadSceneByName() enqueued while insideFrame; applied at the next runFrame's start. */
109
124
  pendingSceneLoad = null;
110
125
  constructor(options) {
@@ -142,11 +157,25 @@ export class Game {
142
157
  // One screen pixel in world units: the shake snaps to it (never the base).
143
158
  pixel: () => (this.resolution ? this.viewHeight / this.resolution.height : null),
144
159
  });
145
- this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
160
+ this.renderer = new THREE.WebGPURenderer({ canvas, antialias: true });
146
161
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
147
- this.scene.background = new THREE.Color(background);
162
+ // The model view on the CPU, as three's WebGL renderer computed it: a Sprite
163
+ // Batch instance carries that same matrix (ADR 0024), so batched and
164
+ // per-sprite draws stay pixel-identical.
165
+ this.renderer.highPrecision = true;
166
+ drawStraightToCanvas(this.renderer);
167
+ mapUvPerVertex(this.renderer);
168
+ this.readiness = new RenderReadiness(this.renderer, (error) => {
169
+ if (!this.disposed)
170
+ activeRuntimeBridgeHook()?.fail?.({ code: 'render-backend-failed', message: error.message });
171
+ });
172
+ this.scene.background = canvasBackground(background);
148
173
  this.camera = new THREE.OrthographicCamera();
149
174
  this.camera.position.z = 10;
175
+ // Emissive drawables sit on their own layer; an unlit frame draws it in place.
176
+ this.camera.layers.enable(EMISSIVE_LAYER);
177
+ const { renderer, spriteBatches, resolution } = this;
178
+ this.frame = new FrameComposer({ game: this, renderer, spriteBatches, resolution });
150
179
  this.pointer = new Pointer(canvas, {
151
180
  camera: this.camera,
152
181
  resolution: this.resolution,
@@ -157,6 +186,19 @@ export class Game {
157
186
  this.resizeObserver = new ResizeObserver(() => this.resize());
158
187
  this.resizeObserver.observe(canvas);
159
188
  }
189
+ /**
190
+ * Resolves once the renderer can draw; rejects, naming both Render
191
+ * Backends, when neither initializes. Every call returns the same promise.
192
+ * Frames before it simulate but draw nothing, so a host awaits it before
193
+ * its first frame (ADR 0025).
194
+ */
195
+ ready() {
196
+ return this.readiness.promise;
197
+ }
198
+ /** The Render Backend the renderer settled on; null until ready() resolved. */
199
+ get backend() {
200
+ return this.readiness.backend;
201
+ }
160
202
  /** Creates a live entity in the scene. */
161
203
  spawn(name) {
162
204
  const entity = new Entity(this, name);
@@ -200,6 +242,8 @@ export class Game {
200
242
  // destroyed below, so an owner's own destroy() cancellation is a no-op.
201
243
  this.time.cancelSceneScoped();
202
244
  this.cameraEffects.unloadScene();
245
+ this.lighting.unloadScene();
246
+ this.post.unloadScene();
203
247
  // An explicit unload means "no scene": a swap queued earlier this frame
204
248
  // would otherwise flush next frame and resurrect one.
205
249
  this.pendingSceneLoad = null;
@@ -306,6 +350,8 @@ export class Game {
306
350
  setSceneRender(json) {
307
351
  this.renderSort = json?.sort === 'y' ? 'y' : null;
308
352
  this.spriteBatches.enabled = json?.batch !== false;
353
+ this.lighting.loadScene(json);
354
+ this.post.loadScene(json);
309
355
  const projection = json?.projection === 'isometric' ? 'isometric' : null;
310
356
  if (projection === this.sceneProjection)
311
357
  return;
@@ -338,6 +384,8 @@ export class Game {
338
384
  this.setViewHeight(this.sceneCamera.zoom);
339
385
  }
340
386
  start() {
387
+ if (this.disposed)
388
+ return;
341
389
  const activation = activeRuntimeBridgeHook();
342
390
  if (activation) {
343
391
  if (!this.runtimeBridge) {
@@ -357,19 +405,27 @@ export class Game {
357
405
  loadScene: (name) => this.loadSceneByName(name),
358
406
  availableScenes: () => this.availableScenes,
359
407
  assets: () => this.assets.status,
408
+ backend: () => this.backend,
360
409
  });
361
410
  activation.register(this.runtimeBridge);
362
411
  this.audio.setSilenced(true);
363
412
  window.addEventListener('pagehide', this.unregisterRuntimeBridge);
364
413
  }
365
414
  this.renderSurface();
415
+ // Started before ready(): the paused frame draws once the renderer can, frame unchanged.
416
+ if (!this.readiness.isReady) {
417
+ this.readiness.whenReady(() => {
418
+ if (this.runtimeBridge)
419
+ this.renderSurface();
420
+ });
421
+ }
366
422
  return;
367
423
  }
368
424
  this.resetClock();
369
- this.renderer.setAnimationLoop((time) => this.tick(time));
425
+ this.setAnimationLoop((time) => this.tick(time));
370
426
  }
371
427
  stop() {
372
- this.renderer.setAnimationLoop(null);
428
+ this.setAnimationLoop(null);
373
429
  }
374
430
  /** Internal: called by Entity.destroy(). Its Anchored Pieces go (or freeze) with it. */
375
431
  removeEntity(entity) {
@@ -392,6 +448,7 @@ export class Game {
392
448
  }
393
449
  /** Shuts the game down completely (loop, input, GPU). */
394
450
  dispose() {
451
+ this.disposed = true;
395
452
  this.stop();
396
453
  this.unregisterRuntimeBridge();
397
454
  this.input.dispose();
@@ -410,7 +467,15 @@ export class Game {
410
467
  // After the entities: their clones go with the cascade above, the
411
468
  // cached bases go here, exactly once (ADR 0019).
412
469
  this.assets.dispose();
413
- this.renderer.dispose();
470
+ this.frame.dispose();
471
+ this.readiness.disposeRenderer();
472
+ }
473
+ /**
474
+ * three installs the loop once its init settled; a failed init is
475
+ * reported by ready(), not by every loop change made before it.
476
+ */
477
+ setAnimationLoop(loop) {
478
+ this.renderer.setAnimationLoop(loop).catch(() => { });
414
479
  }
415
480
  /**
416
481
  * Real-time playback for the Runtime Bridge: the same clock-driven loop
@@ -421,7 +486,7 @@ export class Game {
421
486
  */
422
487
  resumeRuntime(onStep) {
423
488
  this.resetClock();
424
- this.renderer.setAnimationLoop((time) => this.tick(time, onStep));
489
+ this.setAnimationLoop((time) => this.tick(time, onStep));
425
490
  }
426
491
  /** Forgets the clock and any partial step, so the next frame runs no burst. */
427
492
  resetClock() {
@@ -549,40 +614,6 @@ export class Game {
549
614
  this.runtimeBridge = null;
550
615
  this.audio.setSilenced(false);
551
616
  };
552
- /** Under y-sort, re-derives every participant's z from layer band + entity Y. */
553
- applyYSort() {
554
- const singles = [];
555
- const batches = [];
556
- const entries = [];
557
- for (const entity of this.entities) {
558
- for (const component of entity.components) {
559
- if (isYSortBatchParticipant(component)) {
560
- const batchEntries = component.ySortEntries();
561
- batches.push({ participant: component, start: entries.length, count: batchEntries.length });
562
- entries.push(...batchEntries);
563
- }
564
- else if (isYSortParticipant(component)) {
565
- singles.push({ participant: component, index: entries.length });
566
- entries.push({ layer: component.layer, y: entity.node.position.y });
567
- }
568
- }
569
- }
570
- for (const participant of sceneDrainsOf(this).participants()) {
571
- const batchEntries = participant.ySortEntries();
572
- batches.push({ participant, start: entries.length, count: batchEntries.length });
573
- entries.push(...batchEntries);
574
- }
575
- const z = ySortZ(entries);
576
- for (const { participant, index } of singles) {
577
- const sortZ = z[index];
578
- if (sortZ === undefined)
579
- throw new Error(`ySortZ returned no z for y-sort participant ${index}`);
580
- participant.setSortZ(sortZ);
581
- }
582
- for (const { participant, start, count } of batches) {
583
- participant.setSortZs(z.slice(start, start + count));
584
- }
585
- }
586
617
  renderSurface() {
587
618
  if (this.sceneProjection === 'isometric') {
588
619
  for (const entity of this.entities) {
@@ -592,7 +623,7 @@ export class Game {
592
623
  }
593
624
  }
594
625
  if (this.renderSort === 'y')
595
- this.applyYSort();
626
+ applyYSort(this.entities, sceneDrainsOf(this).participants());
596
627
  this.ui.setActive(this.simulate);
597
628
  // The shake offset exists only while drawing (ADR 0020): Anchored Pieces
598
629
  // and the render see the drawn center, then the base comes back exactly,
@@ -603,14 +634,10 @@ export class Game {
603
634
  this.camera.position.y = y + shake.y;
604
635
  try {
605
636
  anchoredPiecesOf(this.ui).place();
606
- if (this.resolution) {
607
- // Letterbox bars: clear the whole canvas, then render inside the scissor.
608
- this.renderer.setScissorTest(false);
609
- this.renderer.setClearColor(0x000000, 1);
610
- this.renderer.clear(true, false, false);
611
- this.renderer.setScissorTest(true);
612
- }
613
- this.spriteBatches.drawFrame(this.scene, this.camera, () => this.renderer.render(this.scene, this.camera));
637
+ // Before ready() the frame simulated, but three cannot draw yet.
638
+ if (!this.readiness.isReady)
639
+ return;
640
+ this.frame.draw();
614
641
  }
615
642
  finally {
616
643
  this.camera.position.x = x;
package/dist/index.d.ts CHANGED
@@ -37,8 +37,10 @@ export type { GamepadControl } from './gamepad.js';
37
37
  export { Pointer } from './pointer.js';
38
38
  export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from './pointer.js';
39
39
  export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
40
- export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
40
+ export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeBridgeFailure, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
41
+ export type { RenderBackend } from './render-readiness.js';
41
42
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
43
+ export type { RuntimeSnapshotLight, RuntimeSnapshotLighting, RuntimeSnapshotPost, } from './runtime-lighting-snapshot.js';
42
44
  export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotCamera, RuntimeSnapshotFilters, RuntimeSnapshotTime, RuntimeSnapshotUi, RuntimeTransformSnapshot, } from './runtime-inspection.js';
43
45
  export type { ArchetypeArt, ArchetypeManifest, BrowserArchetypeManifest, EntityTemplate, } from './archetype.js';
44
46
  export { Stats } from './stats.js';
@@ -46,6 +48,15 @@ export type { StatValue } from './stats.js';
46
48
  export { GameUi } from './ui.js';
47
49
  export type { AnchoredPieceHandle, AttachOptions } from './anchored-pieces.js';
48
50
  export { Sprite } from './components/sprite.js';
51
+ export { Light } from './components/light.js';
52
+ export { GameLighting } from './scene-lighting.js';
53
+ export type { AmbientLightInput } from './scene-lighting.js';
54
+ export { GamePost } from './post-effects.js';
55
+ export { EMISSIVE_LAYER, setEmissive } from './render-layers.js';
56
+ export { lightFalloff, lightMapValue, lightVisibility } from './light-field.js';
57
+ export type { LightField, OccluderGrid, Rgb } from './light-field.js';
58
+ export { sceneRenderIssues } from './scene-render-options.js';
59
+ export type { AmbientLight, ColorGradeEffect, PostEffectsState, SceneRenderIssue, VignetteEffect, } from './scene-render-options.js';
49
60
  export { ParticleEmitter } from './components/particle-emitter.js';
50
61
  export type { ParticleBlend, ParticleDestroyMode, ParticleOverflow, ParticleSpace, ParticleVector, } from './components/particle-emitter.js';
51
62
  export { Solid } from './components/solid.js';
@@ -65,7 +76,7 @@ export type { CollisionBody, CollisionBounds, CollisionPoint, CollisionShape, }
65
76
  export type { EntityWith, NearestQueryContext, NearestSpatialQueryFilter, RayHit, SpatialQuery, SpatialQueryFilter, } from './spatial-query.js';
66
77
  export { Emitter } from './events.js';
67
78
  export { loadScene, spawnFromJson, resolveEntityComponents, resolveProps } from './scene.js';
68
- export type { SceneJson, SceneEntityJson, SceneComponentJson, SceneRegistry, SceneRenderJson, PrefabJson, } from './scene.js';
79
+ export type { SceneJson, SceneEntityJson, SceneComponentJson, SceneRegistry, SceneRenderJson, SceneLightingJson, ScenePostJson, PrefabJson, } from './scene.js';
69
80
  export { ClipPlayer } from './animation/clip-player.js';
70
81
  export type { ClipDef } from './animation/clip-player.js';
71
82
  export { sheetCell, sheetFrameCount, locateFrame } from './animation/sheet.js';
@@ -76,4 +87,4 @@ export { StateMachine, evaluateTrigger, nextTransition } from './state/state-mac
76
87
  export type { StateJson, StateTransitionJson, TriggerEnv } from './state/state-machine.js';
77
88
  export { defineRole, defineStates, installArchetype, logicSet, registeredLogicSets, registeredRoles, resetRegistries, roleDefinition, } from './state/hooks.js';
78
89
  export type { ArchetypeBundle, RoleDefinition, RoleGraph, StateContext, StateHooks, StateLogic, } from './state/hooks.js';
79
- export * as THREE from 'three';
90
+ export * as THREE from 'three/webgpu';
package/dist/index.js CHANGED
@@ -22,6 +22,12 @@ export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
22
22
  export { Stats } from './stats.js';
23
23
  export { GameUi } from './ui.js';
24
24
  export { Sprite } from './components/sprite.js';
25
+ export { Light } from './components/light.js';
26
+ export { GameLighting } from './scene-lighting.js';
27
+ export { GamePost } from './post-effects.js';
28
+ export { EMISSIVE_LAYER, setEmissive } from './render-layers.js';
29
+ export { lightFalloff, lightMapValue, lightVisibility } from './light-field.js';
30
+ export { sceneRenderIssues } from './scene-render-options.js';
25
31
  export { ParticleEmitter } from './components/particle-emitter.js';
26
32
  export { Solid } from './components/solid.js';
27
33
  export { Hitbox } from './components/hitbox.js';
@@ -42,4 +48,6 @@ export { StateMachine, evaluateTrigger, nextTransition } from './state/state-mac
42
48
  export { defineRole, defineStates, installArchetype, logicSet, registeredLogicSets, registeredRoles, resetRegistries, roleDefinition, } from './state/hooks.js';
43
49
  // Explicit escape hatch while our own API grows: a single source of three
44
50
  // for the whole workspace. The thesis is that three stays an implementation detail.
45
- export * as THREE from 'three';
51
+ // It is the WebGPU build the engine draws with (ADR 0025): no classic WebGL renderer,
52
+ // and node materials (TSL) instead of GLSL shaders.
53
+ export * as THREE from 'three/webgpu';
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The CPU reference of a light-map texel (issue #78 CA-5..CA-7): what the
3
+ * light shader computes per texel, written once in plain TypeScript so it
4
+ * can be tested. The shader in `light-map-material.ts` follows it step by
5
+ * step; this file is the specification both are read against.
6
+ */
7
+ /** A light-map value or a light color: three multipliers in 0..1 over the sRGB frame. */
8
+ export type Rgb = [number, number, number];
9
+ /** One Light as the light-map sees it: logical position and radius, its look, its shadow. */
10
+ export interface LightField {
11
+ x: number;
12
+ y: number;
13
+ /** Logical units: the same distance in every grid direction, also on an isometric screen. */
14
+ radius: number;
15
+ color: Rgb;
16
+ intensity: number;
17
+ /** 0 = smooth falloff; N > 0 = N equal steps. */
18
+ bands: number;
19
+ /** 0 = hard shadow edge; up to 1 = the widest penumbra. */
20
+ softness: number;
21
+ castShadows: boolean;
22
+ }
23
+ /**
24
+ * Every Tilemap's solid tiles rasterized onto one logical grid: `solid` holds
25
+ * one byte per cell, row-major, row 0 at `originY` (rows grow with y).
26
+ */
27
+ export interface OccluderGrid {
28
+ originX: number;
29
+ originY: number;
30
+ cellSize: number;
31
+ columns: number;
32
+ rows: number;
33
+ solid: Uint8Array;
34
+ }
35
+ /** A logical point. */
36
+ export interface LogicalPoint {
37
+ x: number;
38
+ y: number;
39
+ }
40
+ /** What every light-map texel of a frame is computed from. */
41
+ export interface LightScene {
42
+ ambient: Rgb;
43
+ lights: readonly LightField[];
44
+ grid: OccluderGrid | null;
45
+ }
46
+ /**
47
+ * Brightness at `t` = distance / radius: 1 at the centre, a smooth ease to 0
48
+ * at the radius and nothing beyond. With `bands` > 0 every lit point takes
49
+ * the top of its step, so the outermost band still reaches the radius.
50
+ */
51
+ export declare function lightFalloff(t: number, bands: number): number;
52
+ /** The render-space box a light covers: a circle's square, or the 2:1 ellipse's box on an isometric screen. */
53
+ export declare function lightFootprint(radius: number, projection: 'isometric' | null): {
54
+ width: number;
55
+ height: number;
56
+ };
57
+ /**
58
+ * Whether the segment from a texel to a light crosses no solid cell other
59
+ * than the texel's own (the lit face of a wall, CA-7). A grid walk
60
+ * (Amanatides–Woo) over every cell the segment enters, the light's included.
61
+ */
62
+ export declare function segmentClear(grid: OccluderGrid, from: LogicalPoint, to: LogicalPoint): boolean;
63
+ /**
64
+ * How far the segment from a texel to a light travels inside solid cells,
65
+ * in logical units, never counting the texel's own cell (its lit face): the
66
+ * same grid walk as `segmentClear`, measuring instead of stopping.
67
+ */
68
+ export declare function segmentPenetration(grid: OccluderGrid, from: LogicalPoint, to: LogicalPoint): number;
69
+ /**
70
+ * How much of a light reaches a texel. Hard (softness 0): 1 or 0. Soft: one
71
+ * walk measures how deep the ray runs through solid cells, and the light
72
+ * fades over the first `softness` cells of it — a ray grazing a wall's
73
+ * corner keeps most of its light, one through the wall none — so the edge
74
+ * is a single transition that widens away from the occluder (PR #150 review).
75
+ */
76
+ export declare function lightVisibility(grid: OccluderGrid | null, light: LightField, point: LogicalPoint): number;
77
+ /** A light-map texel at a logical point: the Ambient Light plus every light's term, clamped per channel. */
78
+ export declare function lightMapValue({ ambient, lights, grid }: LightScene, point: LogicalPoint): Rgb;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The CPU reference of a light-map texel (issue #78 CA-5..CA-7): what the
3
+ * light shader computes per texel, written once in plain TypeScript so it
4
+ * can be tested. The shader in `light-map-material.ts` follows it step by
5
+ * step; this file is the specification both are read against.
6
+ */
7
+ /**
8
+ * Brightness at `t` = distance / radius: 1 at the centre, a smooth ease to 0
9
+ * at the radius and nothing beyond. With `bands` > 0 every lit point takes
10
+ * the top of its step, so the outermost band still reaches the radius.
11
+ */
12
+ export function lightFalloff(t, bands) {
13
+ if (!(t < 1))
14
+ return 0;
15
+ const clamped = Math.max(0, t);
16
+ const smooth = 1 - clamped * clamped * (3 - 2 * clamped);
17
+ if (bands <= 0)
18
+ return smooth;
19
+ return Math.ceil(smooth * bands) / bands;
20
+ }
21
+ /** The render-space box a light covers: a circle's square, or the 2:1 ellipse's box on an isometric screen. */
22
+ export function lightFootprint(radius, projection) {
23
+ if (projection === 'isometric')
24
+ return { width: 2 * Math.SQRT2 * radius, height: Math.SQRT2 * radius };
25
+ return { width: 2 * radius, height: 2 * radius };
26
+ }
27
+ function solidAt(grid, column, row) {
28
+ if (column < 0 || row < 0 || column >= grid.columns || row >= grid.rows)
29
+ return false;
30
+ return grid.solid[row * grid.columns + column] === 1;
31
+ }
32
+ /** The ray parameter of the first cell boundary on one axis, and how much it grows per cell. */
33
+ function axisCrossing(start, delta, cell) {
34
+ if (delta === 0)
35
+ return { next: Number.POSITIVE_INFINITY, step: Number.POSITIVE_INFINITY };
36
+ const step = Math.abs(1 / delta);
37
+ const toBoundary = delta > 0 ? cell + 1 - start : start - cell;
38
+ return { next: toBoundary * step, step };
39
+ }
40
+ /**
41
+ * Whether the segment from a texel to a light crosses no solid cell other
42
+ * than the texel's own (the lit face of a wall, CA-7). A grid walk
43
+ * (Amanatides–Woo) over every cell the segment enters, the light's included.
44
+ */
45
+ export function segmentClear(grid, from, to) {
46
+ const ax = (from.x - grid.originX) / grid.cellSize;
47
+ const ay = (from.y - grid.originY) / grid.cellSize;
48
+ const bx = (to.x - grid.originX) / grid.cellSize;
49
+ const by = (to.y - grid.originY) / grid.cellSize;
50
+ const endColumn = Math.floor(bx);
51
+ const endRow = Math.floor(by);
52
+ const walk = { column: Math.floor(ax), row: Math.floor(ay), nextX: 0, nextY: 0 };
53
+ const x = axisCrossing(ax, bx - ax, walk.column);
54
+ const y = axisCrossing(ay, by - ay, walk.row);
55
+ walk.nextX = x.next;
56
+ walk.nextY = y.next;
57
+ const stepColumn = Math.sign(bx - ax);
58
+ const stepRow = Math.sign(by - ay);
59
+ const cells = Math.abs(endColumn - walk.column) + Math.abs(endRow - walk.row);
60
+ for (let index = 0; index < cells; index += 1) {
61
+ if (walk.nextX < walk.nextY) {
62
+ walk.column += stepColumn;
63
+ walk.nextX += x.step;
64
+ }
65
+ else {
66
+ walk.row += stepRow;
67
+ walk.nextY += y.step;
68
+ }
69
+ if (solidAt(grid, walk.column, walk.row))
70
+ return false;
71
+ }
72
+ return true;
73
+ }
74
+ /**
75
+ * How far the segment from a texel to a light travels inside solid cells,
76
+ * in logical units, never counting the texel's own cell (its lit face): the
77
+ * same grid walk as `segmentClear`, measuring instead of stopping.
78
+ */
79
+ export function segmentPenetration(grid, from, to) {
80
+ const ax = (from.x - grid.originX) / grid.cellSize;
81
+ const ay = (from.y - grid.originY) / grid.cellSize;
82
+ const bx = (to.x - grid.originX) / grid.cellSize;
83
+ const by = (to.y - grid.originY) / grid.cellSize;
84
+ const walk = { column: Math.floor(ax), row: Math.floor(ay), nextX: 0, nextY: 0 };
85
+ const x = axisCrossing(ax, bx - ax, walk.column);
86
+ const y = axisCrossing(ay, by - ay, walk.row);
87
+ walk.nextX = x.next;
88
+ walk.nextY = y.next;
89
+ const cells = Math.abs(Math.floor(bx) - walk.column) + Math.abs(Math.floor(by) - walk.row);
90
+ const length = Math.hypot(to.x - from.x, to.y - from.y);
91
+ let inside = 0;
92
+ for (let index = 0; index < cells; index += 1) {
93
+ let entry;
94
+ if (walk.nextX < walk.nextY) {
95
+ entry = walk.nextX;
96
+ walk.column += Math.sign(bx - ax);
97
+ walk.nextX += x.step;
98
+ }
99
+ else {
100
+ entry = walk.nextY;
101
+ walk.row += Math.sign(by - ay);
102
+ walk.nextY += y.step;
103
+ }
104
+ if (solidAt(grid, walk.column, walk.row))
105
+ inside += (Math.min(walk.nextX, walk.nextY, 1) - entry) * length;
106
+ }
107
+ return inside;
108
+ }
109
+ /**
110
+ * How much of a light reaches a texel. Hard (softness 0): 1 or 0. Soft: one
111
+ * walk measures how deep the ray runs through solid cells, and the light
112
+ * fades over the first `softness` cells of it — a ray grazing a wall's
113
+ * corner keeps most of its light, one through the wall none — so the edge
114
+ * is a single transition that widens away from the occluder (PR #150 review).
115
+ */
116
+ export function lightVisibility(grid, light, point) {
117
+ if (!grid || !light.castShadows)
118
+ return 1;
119
+ if (light.softness <= 0)
120
+ return segmentClear(grid, point, light) ? 1 : 0;
121
+ const depth = Math.min(1, segmentPenetration(grid, point, light) / (light.softness * grid.cellSize));
122
+ return 1 - depth * depth * (3 - 2 * depth);
123
+ }
124
+ /** One light's own term at a logical point: color × intensity × falloff × visibility, never negative. */
125
+ function lightTerm(light, grid, point) {
126
+ const strength = Math.max(0, light.intensity);
127
+ const distance = Math.hypot(point.x - light.x, point.y - light.y);
128
+ const reach = light.radius > 0 ? lightFalloff(distance / light.radius, light.bands) : 0;
129
+ if (strength === 0 || reach === 0)
130
+ return [0, 0, 0];
131
+ const amount = strength * reach * lightVisibility(grid, light, point);
132
+ return [Math.max(0, light.color[0]) * amount, Math.max(0, light.color[1]) * amount, Math.max(0, light.color[2]) * amount];
133
+ }
134
+ const clampUnit = (value) => Math.min(1, Math.max(0, value));
135
+ /** A light-map texel at a logical point: the Ambient Light plus every light's term, clamped per channel. */
136
+ export function lightMapValue({ ambient, lights, grid }, point) {
137
+ const total = [ambient[0], ambient[1], ambient[2]];
138
+ for (const light of lights) {
139
+ const [r, g, b] = lightTerm(light, grid, point);
140
+ total[0] += r;
141
+ total[1] += g;
142
+ total[2] += b;
143
+ }
144
+ return [clampUnit(total[0]), clampUnit(total[1]), clampUnit(total[2])];
145
+ }
@@ -0,0 +1,40 @@
1
+ import * as THREE from 'three/webgpu';
2
+ /** What one light-map mesh draws: its light, in logical units, with its look. */
3
+ export interface LightDraw {
4
+ readonly position: THREE.Vector2;
5
+ /** color × intensity, the term the light adds at its centre. */
6
+ readonly color: THREE.Vector3;
7
+ radius: number;
8
+ bands: number;
9
+ softness: number;
10
+ /** 1 casts shadows, 0 does not. */
11
+ castShadows: number;
12
+ }
13
+ /** Registers what a light-map mesh draws; the shared material reads it per object. */
14
+ export declare function setLightDraw(mesh: THREE.Object3D, draw: LightDraw): void;
15
+ /** The light-map's per-frame inputs every light reads: the projection and the occluder grid. */
16
+ export interface LightMapUniforms {
17
+ /** 1 for an isometric scene: render space is unprojected to logical space per texel. */
18
+ readonly isometric: THREE.UniformNode<'float', number>;
19
+ /** originX, originY, cellSize and 1 when a grid exists (0 casts no shadow at all). */
20
+ readonly grid: THREE.UniformNode<'vec4', THREE.Vector4>;
21
+ /** The grid's columns and rows. */
22
+ readonly gridSize: THREE.UniformNode<'vec2', THREE.Vector2>;
23
+ /** One byte per cell, row 0 first: 255 solid, 0 open. */
24
+ readonly occluders: THREE.TextureNode;
25
+ }
26
+ /** The light-map's shared uniforms, with an empty occluder grid. */
27
+ export declare function createLightMapUniforms(empty: THREE.Texture): LightMapUniforms;
28
+ /**
29
+ * The material every light-map mesh shares (issue #78 CA-5..CA-7): per texel,
30
+ * `color × intensity × falloff(d / radius) × visibility` with `d` measured in
31
+ * logical space (ADR 0009), added onto the Ambient Light the target was
32
+ * cleared to. light-field.ts is its specification.
33
+ */
34
+ export declare function createLightMaterial(uniforms: LightMapUniforms): THREE.MeshBasicNodeMaterial;
35
+ /**
36
+ * The quad that multiplies the light-map over the frame already drawn
37
+ * (ADR 0026): `frame × light`, alpha kept, on the sRGB values as stored —
38
+ * on the canvas, or in a Post Effect's target, which stores them alike.
39
+ */
40
+ export declare function createMultiplyMaterial(light: THREE.Texture): THREE.MeshBasicNodeMaterial;