@netforge/engine 0.0.0-stage → 0.1.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 (244) hide show
  1. package/GAME-DESIGN.md +195 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +30 -0
  4. package/README.md +147 -2
  5. package/dist/api/appearance.d.ts +37 -0
  6. package/dist/api/appearance.js +41 -0
  7. package/dist/api/cards.d.ts +105 -0
  8. package/dist/api/cards.js +133 -0
  9. package/dist/api/collector.d.ts +114 -0
  10. package/dist/api/collector.js +204 -0
  11. package/dist/api/dungeon.d.ts +58 -0
  12. package/dist/api/dungeon.js +216 -0
  13. package/dist/api/harness.d.ts +149 -0
  14. package/dist/api/harness.js +549 -0
  15. package/dist/api/idle.d.ts +71 -0
  16. package/dist/api/idle.js +104 -0
  17. package/dist/api/race-harness.d.ts +3 -0
  18. package/dist/api/race-harness.js +148 -0
  19. package/dist/api/registry.d.ts +241 -0
  20. package/dist/api/registry.js +394 -0
  21. package/dist/api/scheduler.d.ts +56 -0
  22. package/dist/api/scheduler.js +160 -0
  23. package/dist/api/schemas.d.ts +958 -0
  24. package/dist/api/schemas.js +859 -0
  25. package/dist/api/tower.d.ts +127 -0
  26. package/dist/api/tower.js +183 -0
  27. package/dist/game/adapter.d.ts +69 -0
  28. package/dist/game/adapter.js +341 -0
  29. package/dist/game/arena-bot.d.ts +66 -0
  30. package/dist/game/arena-bot.js +188 -0
  31. package/dist/game/bot.d.ts +95 -0
  32. package/dist/game/bot.js +285 -0
  33. package/dist/game/compile.d.ts +97 -0
  34. package/dist/game/compile.js +476 -0
  35. package/dist/game/director.d.ts +42 -0
  36. package/dist/game/director.js +179 -0
  37. package/dist/game/earn-source.d.ts +122 -0
  38. package/dist/game/earn-source.js +386 -0
  39. package/dist/game/earn.d.ts +49 -0
  40. package/dist/game/earn.js +55 -0
  41. package/dist/game/index.d.ts +37 -0
  42. package/dist/game/index.js +25 -0
  43. package/dist/game/platformer.d.ts +156 -0
  44. package/dist/game/platformer.js +619 -0
  45. package/dist/game/profile.d.ts +97 -0
  46. package/dist/game/profile.js +140 -0
  47. package/dist/game/race.d.ts +177 -0
  48. package/dist/game/race.js +386 -0
  49. package/dist/game/racer.d.ts +98 -0
  50. package/dist/game/racer.js +379 -0
  51. package/dist/game/runner.d.ts +286 -0
  52. package/dist/game/runner.js +824 -0
  53. package/dist/game/threats.d.ts +23 -0
  54. package/dist/game/threats.js +49 -0
  55. package/dist/game/view.d.ts +47 -0
  56. package/dist/game/view.js +123 -0
  57. package/dist/index.d.ts +33 -0
  58. package/dist/index.js +24 -0
  59. package/dist/net/bits.d.ts +33 -0
  60. package/dist/net/bits.js +135 -0
  61. package/dist/net/client.d.ts +75 -0
  62. package/dist/net/client.js +151 -0
  63. package/dist/net/index.d.ts +23 -0
  64. package/dist/net/index.js +23 -0
  65. package/dist/net/input.d.ts +49 -0
  66. package/dist/net/input.js +112 -0
  67. package/dist/net/interpolation.d.ts +37 -0
  68. package/dist/net/interpolation.js +97 -0
  69. package/dist/net/netsim.d.ts +22 -0
  70. package/dist/net/netsim.js +52 -0
  71. package/dist/net/prediction.d.ts +31 -0
  72. package/dist/net/prediction.js +101 -0
  73. package/dist/net/profile-client.d.ts +75 -0
  74. package/dist/net/profile-client.js +173 -0
  75. package/dist/net/protocol.d.ts +208 -0
  76. package/dist/net/protocol.js +8 -0
  77. package/dist/net/room-client.d.ts +57 -0
  78. package/dist/net/room-client.js +144 -0
  79. package/dist/net/schema.d.ts +78 -0
  80. package/dist/net/schema.js +121 -0
  81. package/dist/net/sim-adapter.d.ts +134 -0
  82. package/dist/net/sim-adapter.js +22 -0
  83. package/dist/net/snapshot.d.ts +61 -0
  84. package/dist/net/snapshot.js +301 -0
  85. package/dist/rules/adapter.d.ts +53 -0
  86. package/dist/rules/adapter.js +261 -0
  87. package/dist/rules/cards.d.ts +115 -0
  88. package/dist/rules/cards.js +547 -0
  89. package/dist/rules/collector.d.ts +161 -0
  90. package/dist/rules/collector.js +732 -0
  91. package/dist/rules/genres.d.ts +13 -0
  92. package/dist/rules/genres.js +38 -0
  93. package/dist/rules/harness.d.ts +16 -0
  94. package/dist/rules/harness.js +127 -0
  95. package/dist/rules/idle.d.ts +115 -0
  96. package/dist/rules/idle.js +370 -0
  97. package/dist/rules/index.d.ts +16 -0
  98. package/dist/rules/index.js +14 -0
  99. package/dist/rules/kit.d.ts +171 -0
  100. package/dist/rules/kit.js +88 -0
  101. package/dist/rules/tower.d.ts +214 -0
  102. package/dist/rules/tower.js +655 -0
  103. package/dist/runtime/arena.d.ts +86 -0
  104. package/dist/runtime/arena.js +269 -0
  105. package/dist/runtime/assets.d.ts +26 -0
  106. package/dist/runtime/assets.js +35 -0
  107. package/dist/runtime/camera.d.ts +113 -0
  108. package/dist/runtime/camera.js +247 -0
  109. package/dist/runtime/crowd.d.ts +18 -0
  110. package/dist/runtime/crowd.js +62 -0
  111. package/dist/runtime/feel/audio.d.ts +150 -0
  112. package/dist/runtime/feel/audio.js +327 -0
  113. package/dist/runtime/feel/clock.d.ts +52 -0
  114. package/dist/runtime/feel/clock.js +80 -0
  115. package/dist/runtime/feel/flash.d.ts +28 -0
  116. package/dist/runtime/feel/flash.js +60 -0
  117. package/dist/runtime/feel/index.d.ts +27 -0
  118. package/dist/runtime/feel/index.js +16 -0
  119. package/dist/runtime/feel/kit.d.ts +159 -0
  120. package/dist/runtime/feel/kit.js +226 -0
  121. package/dist/runtime/feel/knockback.d.ts +41 -0
  122. package/dist/runtime/feel/knockback.js +70 -0
  123. package/dist/runtime/feel/noise.d.ts +6 -0
  124. package/dist/runtime/feel/noise.js +30 -0
  125. package/dist/runtime/feel/numbers.d.ts +91 -0
  126. package/dist/runtime/feel/numbers.js +172 -0
  127. package/dist/runtime/feel/rumble.d.ts +40 -0
  128. package/dist/runtime/feel/rumble.js +58 -0
  129. package/dist/runtime/feel/sound.d.ts +96 -0
  130. package/dist/runtime/feel/sound.js +176 -0
  131. package/dist/runtime/feel/spring.d.ts +42 -0
  132. package/dist/runtime/feel/spring.js +95 -0
  133. package/dist/runtime/feel/trauma.d.ts +48 -0
  134. package/dist/runtime/feel/trauma.js +76 -0
  135. package/dist/runtime/feel/tween.d.ts +66 -0
  136. package/dist/runtime/feel/tween.js +180 -0
  137. package/dist/runtime/feel/types.d.ts +30 -0
  138. package/dist/runtime/feel/types.js +5 -0
  139. package/dist/runtime/index.d.ts +28 -0
  140. package/dist/runtime/index.js +26 -0
  141. package/dist/runtime/input.d.ts +38 -0
  142. package/dist/runtime/input.js +159 -0
  143. package/dist/runtime/interpolate.d.ts +6 -0
  144. package/dist/runtime/interpolate.js +13 -0
  145. package/dist/runtime/render/animation.d.ts +63 -0
  146. package/dist/runtime/render/animation.js +128 -0
  147. package/dist/runtime/render/atmosphere.d.ts +32 -0
  148. package/dist/runtime/render/atmosphere.js +59 -0
  149. package/dist/runtime/render/crowd-motion.d.ts +68 -0
  150. package/dist/runtime/render/crowd-motion.js +163 -0
  151. package/dist/runtime/render/decals.d.ts +64 -0
  152. package/dist/runtime/render/decals.js +222 -0
  153. package/dist/runtime/render/highlight.d.ts +29 -0
  154. package/dist/runtime/render/highlight.js +110 -0
  155. package/dist/runtime/render/index.d.ts +35 -0
  156. package/dist/runtime/render/index.js +22 -0
  157. package/dist/runtime/render/lighting.d.ts +50 -0
  158. package/dist/runtime/render/lighting.js +142 -0
  159. package/dist/runtime/render/particles.d.ts +117 -0
  160. package/dist/runtime/render/particles.js +289 -0
  161. package/dist/runtime/render/post.d.ts +92 -0
  162. package/dist/runtime/render/post.js +191 -0
  163. package/dist/runtime/render/presets.d.ts +57 -0
  164. package/dist/runtime/render/presets.js +116 -0
  165. package/dist/runtime/render/quality.d.ts +113 -0
  166. package/dist/runtime/render/quality.js +180 -0
  167. package/dist/runtime/render/rig.d.ts +29 -0
  168. package/dist/runtime/render/rig.js +107 -0
  169. package/dist/runtime/render/sprite.d.ts +34 -0
  170. package/dist/runtime/render/sprite.js +58 -0
  171. package/dist/runtime/render/stack.d.ts +74 -0
  172. package/dist/runtime/render/stack.js +132 -0
  173. package/dist/runtime/render/vat.d.ts +78 -0
  174. package/dist/runtime/render/vat.js +219 -0
  175. package/dist/runtime/renderer.d.ts +19 -0
  176. package/dist/runtime/renderer.js +18 -0
  177. package/dist/runtime/stats.d.ts +11 -0
  178. package/dist/runtime/stats.js +29 -0
  179. package/dist/runtime/styles/handdrawn/ground.d.ts +11 -0
  180. package/dist/runtime/styles/handdrawn/ground.js +282 -0
  181. package/dist/runtime/styles/handdrawn/index.d.ts +4 -0
  182. package/dist/runtime/styles/handdrawn/index.js +94 -0
  183. package/dist/runtime/styles/handdrawn/ink.d.ts +31 -0
  184. package/dist/runtime/styles/handdrawn/ink.js +68 -0
  185. package/dist/runtime/styles/handdrawn/looks.d.ts +18 -0
  186. package/dist/runtime/styles/handdrawn/looks.js +42 -0
  187. package/dist/runtime/styles/handdrawn/paint.d.ts +59 -0
  188. package/dist/runtime/styles/handdrawn/paint.js +129 -0
  189. package/dist/runtime/styles/handdrawn/palette.d.ts +49 -0
  190. package/dist/runtime/styles/handdrawn/palette.js +70 -0
  191. package/dist/runtime/styles/handdrawn/textures.d.ts +27 -0
  192. package/dist/runtime/styles/handdrawn/textures.js +150 -0
  193. package/dist/runtime/styles/handdrawn/vfx.d.ts +14 -0
  194. package/dist/runtime/styles/handdrawn/vfx.js +73 -0
  195. package/dist/runtime/styles/index.d.ts +9 -0
  196. package/dist/runtime/styles/index.js +32 -0
  197. package/dist/runtime/styles/kit.d.ts +193 -0
  198. package/dist/runtime/styles/kit.js +1 -0
  199. package/dist/runtime/styles/pixel/index.d.ts +46 -0
  200. package/dist/runtime/styles/pixel/index.js +167 -0
  201. package/dist/runtime/styles/ps1/index.d.ts +29 -0
  202. package/dist/runtime/styles/ps1/index.js +41 -0
  203. package/dist/runtime/styles/ps1/lights.d.ts +47 -0
  204. package/dist/runtime/styles/ps1/lights.js +146 -0
  205. package/dist/runtime/styles/ps1/materials.d.ts +62 -0
  206. package/dist/runtime/styles/ps1/materials.js +285 -0
  207. package/dist/runtime/styles/ps1/pipeline.d.ts +52 -0
  208. package/dist/runtime/styles/ps1/pipeline.js +111 -0
  209. package/dist/runtime/styles/ps1/surfaces.d.ts +44 -0
  210. package/dist/runtime/styles/ps1/surfaces.js +228 -0
  211. package/dist/runtime/styles/toon/index.d.ts +5 -0
  212. package/dist/runtime/styles/toon/index.js +178 -0
  213. package/dist/runtime/styles/toon/ink.d.ts +39 -0
  214. package/dist/runtime/styles/toon/ink.js +73 -0
  215. package/dist/runtime/styles/toon/look.d.ts +10 -0
  216. package/dist/runtime/styles/toon/look.js +112 -0
  217. package/dist/runtime/styles/toon/material.d.ts +74 -0
  218. package/dist/runtime/styles/toon/material.js +208 -0
  219. package/dist/runtime/styles/toon/normals.d.ts +26 -0
  220. package/dist/runtime/styles/toon/normals.js +127 -0
  221. package/dist/runtime/styles/toon/shadows.d.ts +24 -0
  222. package/dist/runtime/styles/toon/shadows.js +42 -0
  223. package/dist/runtime/styles/toon/vfx.d.ts +15 -0
  224. package/dist/runtime/styles/toon/vfx.js +47 -0
  225. package/dist/runtime/styles/view.d.ts +35 -0
  226. package/dist/runtime/styles/view.js +34 -0
  227. package/dist/runtime/styles/voxel/core.d.ts +114 -0
  228. package/dist/runtime/styles/voxel/core.js +577 -0
  229. package/dist/runtime/styles/voxel/geometry.d.ts +27 -0
  230. package/dist/runtime/styles/voxel/geometry.js +40 -0
  231. package/dist/runtime/styles/voxel/index.d.ts +21 -0
  232. package/dist/runtime/styles/voxel/index.js +23 -0
  233. package/dist/runtime/styles/voxel/kit.d.ts +6 -0
  234. package/dist/runtime/styles/voxel/kit.js +11 -0
  235. package/dist/runtime/styles/voxel/look.d.ts +10 -0
  236. package/dist/runtime/styles/voxel/look.js +16 -0
  237. package/dist/runtime/styles/voxel/props.d.ts +9 -0
  238. package/dist/runtime/styles/voxel/props.js +52 -0
  239. package/dist/runtime/styles/voxel/shapes.d.ts +41 -0
  240. package/dist/runtime/styles/voxel/shapes.js +83 -0
  241. package/dist/runtime/styles/voxel/world.d.ts +49 -0
  242. package/dist/runtime/styles/voxel/world.js +422 -0
  243. package/llms.txt +183 -0
  244. package/package.json +82 -3
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Turns whatever a template put in the scene into PS1 geometry and
3
+ * materials, without the template knowing: every mesh is found once, and
4
+ * - its vertices snap to the low-res pixel grid (the wobble),
5
+ * - its textures map affinely (no perspective correction: textures swim
6
+ * on big polygons), sample nearest, and stay at console sizes,
7
+ * - lit materials light per vertex from the shared VertexLights (Gouraud:
8
+ * smooth below a crease angle, no specular, no shadow maps),
9
+ * - big planes and boxes are tessellated (as console games did, to keep
10
+ * the warping and the vertex lighting in check),
11
+ * - meshes tagged `userData.surface = "<role>"` get the style's texture for
12
+ * that role in world-space box UVs (ground, walls, pillars).
13
+ *
14
+ * Patching overrides three methods on the material instance itself
15
+ * (vertex output, lighting, cache key), so materials the template keeps
16
+ * and mutates (opacity fades, hit flashes, crowd motion) keep working.
17
+ */
18
+ import { BufferAttribute, BoxGeometry, NearestFilter, PlaneGeometry, Quaternion, Vector3 } from "three/webgpu";
19
+ import { toCreasedNormals } from "three/addons/utils/BufferGeometryUtils.js";
20
+ import { cameraProjectionMatrix, context, diffuseColor, emissive, float, floor, materialEmissive, materialMetalness, mix, positionView, uniform, varying, vec3, vec4 } from "three/tsl";
21
+ import { imageAverage, matchTint } from "./surfaces.js";
22
+ /** Lit material types (lit per vertex); basic ones only snap and map affinely. */
23
+ const LIT = /^Mesh(Standard|Physical|Lambert|Phong|Toon)(Node)?Material$/;
24
+ const UNLIT = /^MeshBasic(Node)?Material$/;
25
+ /** Longest texture edge kept as is; larger ones are shrunk to SHRINK_TO. */
26
+ const MAX_TEXTURE = 256;
27
+ const SHRINK_TO = 128;
28
+ /** Big flat cells are cut to about this size, m. */
29
+ const MAX_CELL = 3;
30
+ /** Share of the diffuse light a fully metallic surface loses (the physical renderer's metal draws darker). */
31
+ const METAL_DIM = 0.75;
32
+ /** Faces meeting at less than this angle share a smooth (Gouraud) normal. */
33
+ const CREASE = (55 * Math.PI) / 180;
34
+ export class Ps1Patcher {
35
+ lights;
36
+ resolution;
37
+ surfaces;
38
+ /** 0 = perspective-correct textures, 1 = full affine (the console). */
39
+ affine = uniform(1);
40
+ /** Vertex snap grid as a fraction of the low-res pixel grid (1 = one pixel). */
41
+ snap = uniform(1);
42
+ seenMaterials = new WeakSet();
43
+ seenGeometry = new WeakSet();
44
+ seenTextures = new WeakSet();
45
+ seenMeshes = new WeakSet();
46
+ irradianceVarying = null;
47
+ /** Meshes and materials patched so far (tests and stats). */
48
+ counts = { meshes: 0, materials: 0, geometries: 0, surfaces: 0, textures: 0 };
49
+ constructor(lights, resolution, surfaces) {
50
+ this.lights = lights;
51
+ this.resolution = resolution;
52
+ this.surfaces = surfaces;
53
+ }
54
+ /** Snap a clip-space position to the low-res pixel grid. */
55
+ snapClip(clip) {
56
+ const half = this.resolution.mul(0.5).mul(this.snap);
57
+ const w = clip.w;
58
+ const ndc = clip.xy.div(w);
59
+ const snapped = floor(ndc.mul(half).add(0.5)).div(half).mul(w);
60
+ return vec4(w.greaterThan(1e-4).select(snapped, clip.xy), clip.z, w);
61
+ }
62
+ /** Screen-linear (affine) UVs: interpolate uv*w and w, divide per pixel. */
63
+ affineUV(base) {
64
+ const uv0 = base;
65
+ const w = cameraProjectionMatrix.mul(vec4(positionView, 1)).w;
66
+ const a = varying(vec3(uv0.mul(w), w));
67
+ return mix(uv0, a.xy.div(a.z), this.affine);
68
+ }
69
+ light() {
70
+ this.irradianceVarying ??= varying(this.lights.irradiance(), "v_ps1Light");
71
+ return this.irradianceVarying;
72
+ }
73
+ /** Walk the scene and patch what is new. Cheap after the first pass (weak sets). */
74
+ scan(root) {
75
+ root.traverse((o) => {
76
+ const mesh = o;
77
+ if (!mesh.isMesh || this.seenMeshes.has(mesh))
78
+ return;
79
+ this.seenMeshes.add(mesh);
80
+ this.patchMesh(mesh);
81
+ });
82
+ }
83
+ patchMesh(mesh) {
84
+ const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
85
+ const lit = mats.some((m) => LIT.test(m.type));
86
+ const role = typeof mesh.userData.surface === "string" ? mesh.userData.surface : null;
87
+ const surface = role && lit ? this.surfaces(role) : null;
88
+ if (surface) {
89
+ this.surface(mesh, surface);
90
+ this.counts.surfaces++;
91
+ }
92
+ else if (lit)
93
+ this.smooth(mesh.geometry);
94
+ for (const m of mats)
95
+ this.patchMaterial(m);
96
+ this.counts.meshes++;
97
+ }
98
+ /** Smooth normals below the crease angle, in place (Gouraud on organic shapes, hard edges on boxes). */
99
+ smooth(g) {
100
+ if (this.seenGeometry.has(g))
101
+ return;
102
+ this.seenGeometry.add(g);
103
+ const pos = g.getAttribute("position");
104
+ if (!pos || pos.count < 3 || pos.count > 200_000)
105
+ return;
106
+ // Skinned and morphed meshes keep their own normals (the bind pose would need re-skinning to crease safely).
107
+ if (g.morphAttributes.position)
108
+ return;
109
+ const out = toCreasedNormals(g, CREASE);
110
+ if (out !== g) {
111
+ // An indexed source came back as a new, non-indexed geometry: move its attributes onto the original.
112
+ g.setIndex(null);
113
+ for (const name of Object.keys(out.attributes)) {
114
+ const a = out.getAttribute(name);
115
+ if (a)
116
+ g.setAttribute(name, a);
117
+ }
118
+ }
119
+ this.counts.geometries++;
120
+ }
121
+ /** A surface: tessellate big flat cells, world-space box UVs every `metres`, the style's texture. */
122
+ surface(mesh, src) {
123
+ const { texture, metres } = src;
124
+ mesh.updateWorldMatrix(true, false);
125
+ const s = new Vector3();
126
+ mesh.matrixWorld.decompose(new Vector3(), new Quaternion(), s);
127
+ let g = tessellate(mesh.geometry, s);
128
+ g = g.index ? g.toNonIndexed() : g.clone();
129
+ const pos = g.getAttribute("position");
130
+ const n = pos.count;
131
+ const uv = new Float32Array(n * 2);
132
+ const m = mesh.matrixWorld;
133
+ const a = new Vector3();
134
+ const b = new Vector3();
135
+ const c = new Vector3();
136
+ const fn = new Vector3();
137
+ const wp = new Vector3();
138
+ for (let t = 0; t + 2 < n; t += 3) {
139
+ a.fromBufferAttribute(pos, t).applyMatrix4(m);
140
+ b.fromBufferAttribute(pos, t + 1).applyMatrix4(m);
141
+ c.fromBufferAttribute(pos, t + 2).applyMatrix4(m);
142
+ fn.subVectors(c, b).cross(a.clone().sub(b)).normalize();
143
+ const ax = Math.abs(fn.x);
144
+ const ay = Math.abs(fn.y);
145
+ const az = Math.abs(fn.z);
146
+ for (let k = 0; k < 3; k++) {
147
+ wp.fromBufferAttribute(pos, t + k).applyMatrix4(m);
148
+ let u;
149
+ let v;
150
+ if (ay >= ax && ay >= az)
151
+ [u, v] = [wp.x, wp.z];
152
+ else if (ax >= az)
153
+ [u, v] = [wp.z, wp.y];
154
+ else
155
+ [u, v] = [wp.x, wp.y];
156
+ uv[(t + k) * 2] = u / metres;
157
+ uv[(t + k) * 2 + 1] = v / metres;
158
+ }
159
+ }
160
+ g.setAttribute("uv", new BufferAttribute(uv, 2));
161
+ const smooth = toCreasedNormals(g, CREASE);
162
+ mesh.geometry = smooth;
163
+ this.seenGeometry.add(smooth);
164
+ // The texture in place of the template's colour, on the template's own material (it may fade it later),
165
+ // tinted to the brightness and roughly the hue the template drew there, so the scene keeps its mood
166
+ // and the actors keep standing out against it.
167
+ for (const mat of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) {
168
+ const m = mat;
169
+ if (m.map === texture)
170
+ continue;
171
+ const c = m.color;
172
+ const base = c ? [c.r, c.g, c.b] : [1, 1, 1];
173
+ const mapAvg = m.map ? imageAverage(m.map.image) : null;
174
+ const want = mapAvg ? [base[0] * mapAvg[0], base[1] * mapAvg[1], base[2] * mapAvg[2]] : base;
175
+ m.map = texture;
176
+ src.onAverage((avg) => {
177
+ const t = matchTint(want, avg);
178
+ m.color?.setRGB(t[0], t[1], t[2]);
179
+ });
180
+ }
181
+ }
182
+ patchTexture(t) {
183
+ if (!t || this.seenTextures.has(t))
184
+ return;
185
+ this.seenTextures.add(t);
186
+ t.magFilter = NearestFilter;
187
+ t.minFilter = NearestFilter;
188
+ t.generateMipmaps = false;
189
+ t.anisotropy = 1;
190
+ const img = t.image;
191
+ const w = img?.width ?? 0;
192
+ const h = img?.height ?? 0;
193
+ if (w > MAX_TEXTURE || h > MAX_TEXTURE) {
194
+ const shrunk = shrink(t.image, w, h);
195
+ if (shrunk)
196
+ t.image = shrunk;
197
+ }
198
+ t.needsUpdate = true;
199
+ this.counts.textures++;
200
+ }
201
+ patchMaterial(mat) {
202
+ if (this.seenMaterials.has(mat))
203
+ return;
204
+ this.seenMaterials.add(mat);
205
+ const lit = LIT.test(mat.type);
206
+ if (!lit && !UNLIT.test(mat.type))
207
+ return;
208
+ const m = mat;
209
+ if (m.__ps1)
210
+ return;
211
+ m.__ps1 = true;
212
+ this.patchTexture(m.map);
213
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
214
+ const self = this;
215
+ m.setupModelViewProjection = function (builder) {
216
+ const base = Object.getPrototypeOf(this).setupModelViewProjection.call(this, builder);
217
+ return self.snapClip(base);
218
+ };
219
+ m.customProgramCacheKey = function () {
220
+ return `${Object.getPrototypeOf(this).customProgramCacheKey.call(this)}|ps1`;
221
+ };
222
+ if (m.contextNode === undefined || m.contextNode === null) {
223
+ m.contextNode = context({ getUV: (tex) => self.affineUV(tex.getDefaultUV()) });
224
+ }
225
+ if (lit) {
226
+ // Gouraud needs vertex normals; flat shading would derive face normals per pixel.
227
+ m.flatShading = false;
228
+ m.setupLighting = function () {
229
+ // Metal has little diffuse light: glTF models default to fully metallic, and without the dimming
230
+ // they would glow white where the physical renderer draws them dark (no environment map to reflect).
231
+ const metal = typeof this.metalness === "number" ? (this.metalnessNode ?? materialMetalness) : float(0);
232
+ let out = diffuseColor.rgb.mul(self.light()).mul(float(1).sub(metal.mul(METAL_DIM)));
233
+ const e = this.emissiveNode ?? (this.emissive?.isColor === true ? materialEmissive : null);
234
+ if (e) {
235
+ emissive.assign(vec3(e));
236
+ out = out.add(emissive);
237
+ }
238
+ return out;
239
+ };
240
+ }
241
+ m.needsUpdate = true;
242
+ this.counts.materials++;
243
+ }
244
+ }
245
+ /** Tessellate a PlaneGeometry or BoxGeometry whose cells are larger than MAX_CELL world metres. */
246
+ export function tessellate(g, worldScale) {
247
+ const cells = (len, scale) => Math.max(1, Math.min(160, Math.ceil((len * Math.abs(scale)) / MAX_CELL)));
248
+ if (g.type === "PlaneGeometry") {
249
+ const p = g.parameters;
250
+ const sx = cells(p.width, worldScale.x);
251
+ const sy = cells(p.height, worldScale.y);
252
+ if (sx > p.widthSegments || sy > p.heightSegments)
253
+ return new PlaneGeometry(p.width, p.height, sx, sy);
254
+ }
255
+ else if (g.type === "BoxGeometry") {
256
+ const p = g.parameters;
257
+ const sx = cells(p.width, worldScale.x);
258
+ const sy = cells(p.height, worldScale.y);
259
+ const sz = cells(p.depth, worldScale.z);
260
+ if (sx > p.widthSegments || sy > p.heightSegments || sz > p.depthSegments)
261
+ return new BoxGeometry(p.width, p.height, p.depth, sx, sy, sz);
262
+ }
263
+ return g;
264
+ }
265
+ /** Shrink an image to SHRINK_TO on its long edge (a console-sized texture). */
266
+ function shrink(src, w, h) {
267
+ if (typeof document === "undefined")
268
+ return null;
269
+ const k = SHRINK_TO / Math.max(w, h);
270
+ const c = document.createElement("canvas");
271
+ c.width = Math.max(1, Math.round(w * k));
272
+ c.height = Math.max(1, Math.round(h * k));
273
+ const g = c.getContext("2d");
274
+ if (!g)
275
+ return null;
276
+ g.imageSmoothingEnabled = true;
277
+ g.imageSmoothingQuality = "high";
278
+ try {
279
+ g.drawImage(src, 0, 0, c.width, c.height);
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ return c;
285
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The PS1 frame: the scene draws into a small target (about 240 lines on
3
+ * the short side of the screen, no anti-aliasing), then one full-screen pass
4
+ * grades it, converts it to display colour, and quantises it to 15-bit
5
+ * colour (5 bits a channel) through the console's 4x4 ordered dither,
6
+ * sampling each low-res pixel as a crisp block. An optional faint scanline
7
+ * darkens the last device row of each block.
8
+ *
9
+ * The internal resolution follows the canvas: lines = short side / k, where
10
+ * k is the whole number of device pixels per block nearest to the target,
11
+ * so every block is the same size on screen.
12
+ */
13
+ import { RenderPipeline, Vector2 } from "three/webgpu";
14
+ import type { Camera, Scene, WebGPURenderer } from "three/webgpu";
15
+ import type { PostFX } from "../../render/post.js";
16
+ export interface Ps1PipelineOptions {
17
+ /** Target lines on the short side of the screen. Default 240. */
18
+ readonly lines?: number;
19
+ /** Colour levels per channel after quantising (31 = the PS1's 15-bit framebuffer). */
20
+ readonly levels?: number;
21
+ /** Ordered-dither strength in quantisation steps (1 = the console's matrix). */
22
+ readonly dither?: number;
23
+ /** Scanline darkening on the last device row of each block, 0..1. Default 0.1. */
24
+ readonly scanline?: number;
25
+ }
26
+ export declare class Ps1Pipeline {
27
+ private readonly renderer;
28
+ readonly pipeline: RenderPipeline;
29
+ /** Internal resolution in pixels (the vertex-snap grid). */
30
+ readonly resolution: import("three/webgpu").UniformNode<"vec2", Vector2>;
31
+ /** Device pixels per low-res block (for the scanline). */
32
+ readonly block: import("three/webgpu").UniformNode<"float", number>;
33
+ readonly levels: import("three/webgpu").UniformNode<"float", number>;
34
+ readonly dither: import("three/webgpu").UniformNode<"float", number>;
35
+ readonly scanline: import("three/webgpu").UniformNode<"float", number>;
36
+ private readonly grade;
37
+ private readonly scenePass;
38
+ private readonly lines;
39
+ private readonly size;
40
+ constructor(renderer: WebGPURenderer, scene: Scene, camera: Camera, post: PostFX | undefined, opts?: Ps1PipelineOptions);
41
+ private build;
42
+ /** Fit the small target to the canvas: whole device pixels per block, about `lines` on the short side. */
43
+ private fit;
44
+ render(): void;
45
+ dispose(): void;
46
+ }
47
+ /** For tests: the block grid for a canvas size and a line target. */
48
+ export declare function blockGrid(width: number, height: number, lines: number): {
49
+ k: number;
50
+ w: number;
51
+ h: number;
52
+ };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The PS1 frame: the scene draws into a small target (about 240 lines on
3
+ * the short side of the screen, no anti-aliasing), then one full-screen pass
4
+ * grades it, converts it to display colour, and quantises it to 15-bit
5
+ * colour (5 bits a channel) through the console's 4x4 ordered dither,
6
+ * sampling each low-res pixel as a crisp block. An optional faint scanline
7
+ * darkens the last device row of each block.
8
+ *
9
+ * The internal resolution follows the canvas: lines = short side / k, where
10
+ * k is the whole number of device pixels per block nearest to the target,
11
+ * so every block is the same size on screen.
12
+ */
13
+ import { RenderPipeline, Vector2, Vector3 } from "three/webgpu";
14
+ import { dot, float, floor, fract, length, max, mix, pass, pow, renderOutput, screenUV, smoothstep, uniform, vec3, vec4 } from "three/tsl";
15
+ /** 4x4 Bayer threshold in [0, 1) for an integer pixel coordinate. */
16
+ function bayer4(p) {
17
+ const b2 = (a) => fract(a.x.mul(0.5).add(a.y.mul(a.y).mul(0.75)));
18
+ return b2(floor(p.mul(0.5))).mul(0.25).add(b2(floor(p)));
19
+ }
20
+ export class Ps1Pipeline {
21
+ renderer;
22
+ pipeline;
23
+ /** Internal resolution in pixels (the vertex-snap grid). */
24
+ resolution = uniform(new Vector2(320, 240));
25
+ /** Device pixels per low-res block (for the scanline). */
26
+ block = uniform(3);
27
+ levels = uniform(31);
28
+ dither = uniform(1);
29
+ scanline = uniform(0.1);
30
+ grade;
31
+ scenePass;
32
+ lines;
33
+ size = new Vector2();
34
+ constructor(renderer, scene, camera, post, opts = {}) {
35
+ this.renderer = renderer;
36
+ this.lines = opts.lines ?? 240;
37
+ this.levels.value = opts.levels ?? 31;
38
+ this.dither.value = opts.dither ?? 1;
39
+ this.scanline.value = opts.scanline ?? 0.1;
40
+ this.grade = post ?? {
41
+ exposure: uniform(1),
42
+ contrast: uniform(1),
43
+ saturation: uniform(1),
44
+ shadowTint: uniform(new Vector3(1, 1, 1)),
45
+ highlightTint: uniform(new Vector3(1, 1, 1)),
46
+ vignette: uniform(0.25),
47
+ hurt: uniform(0),
48
+ };
49
+ this.pipeline = new RenderPipeline(renderer);
50
+ this.pipeline.outputColorTransform = false;
51
+ // No MSAA in the small target: hard polygon edges are part of the look.
52
+ this.scenePass = pass(scene, camera, { samples: 0 });
53
+ this.build();
54
+ }
55
+ build() {
56
+ const g = this.grade;
57
+ const res = this.resolution;
58
+ const color = this.scenePass.getTextureNode("output");
59
+ // Sample the centre of the low-res pixel this screen pixel falls in: crisp blocks on any backend.
60
+ const px = floor(screenUV.mul(res));
61
+ const uv = px.add(0.5).div(res);
62
+ let c = color.sample(uv).rgb;
63
+ // The stack's grade, in linear light before tone mapping (same maths as PostFX).
64
+ c = c.mul(g.exposure);
65
+ const luma = dot(c, vec3(0.2126, 0.7152, 0.0722));
66
+ c = mix(vec3(luma), c, g.saturation);
67
+ c = pow(max(c, vec3(1e-5)).div(0.18), vec3(g.contrast)).mul(0.18);
68
+ c = c.mul(mix(g.shadowTint, g.highlightTint, smoothstep(0.0, 1.0, luma)));
69
+ // Vignette and the hurt pulse on the block grid, so they step like the rest of the frame.
70
+ const d = length(uv.sub(0.5)).mul(1.414);
71
+ c = c.mul(float(1).sub(smoothstep(0.35, 1.05, d).mul(g.vignette)));
72
+ const hurtEdge = smoothstep(0.45, 1.15, d).mul(g.hurt).mul(0.85);
73
+ c = mix(c, c.mul(vec3(1.25, 0.35, 0.3)).add(vec3(0.22, 0.0, 0.0)), hurtEdge);
74
+ // Tone map and convert to display colour once, then quantise in display space like the console's DAC.
75
+ const display = renderOutput(vec4(c, 1)).rgb;
76
+ const t = bayer4(px).sub(0.5).mul(this.dither);
77
+ let q = floor(display.mul(this.levels).add(0.5).add(t)).div(this.levels);
78
+ q = q.clamp(0, 1);
79
+ // A faint scanline: the last device row of each block, only when blocks are 3+ device pixels tall.
80
+ const row = fract(screenUV.y.mul(res.y));
81
+ const lastRow = row.greaterThan(float(1).sub(float(1).div(max(this.block, float(1)))));
82
+ const scan = lastRow.and(this.block.greaterThanEqual(3)).select(float(1).sub(this.scanline), float(1));
83
+ this.pipeline.outputNode = vec4(q.mul(scan), 1);
84
+ this.pipeline.needsUpdate = true;
85
+ }
86
+ /** Fit the small target to the canvas: whole device pixels per block, about `lines` on the short side. */
87
+ fit() {
88
+ const s = this.renderer.getDrawingBufferSize(this.size);
89
+ const { k, w, h } = blockGrid(s.x, s.y, this.lines);
90
+ if (w !== this.resolution.value.x || h !== this.resolution.value.y) {
91
+ this.resolution.value.set(w, h);
92
+ this.block.value = k;
93
+ // A hair over 1/k so floor(size x scale) lands on w and h exactly.
94
+ this.scenePass.setResolutionScale((1 + 1e-4) / k);
95
+ }
96
+ }
97
+ render() {
98
+ this.fit();
99
+ this.pipeline.render();
100
+ }
101
+ dispose() {
102
+ this.pipeline.dispose();
103
+ this.scenePass.dispose();
104
+ }
105
+ }
106
+ /** For tests: the block grid for a canvas size and a line target. */
107
+ export function blockGrid(width, height, lines) {
108
+ const short = Math.max(1, Math.min(width, height));
109
+ const k = Math.max(1, Math.round(short / lines));
110
+ return { k, w: Math.max(1, Math.floor(width / k)), h: Math.max(1, Math.floor(height / k)) };
111
+ }
@@ -0,0 +1,44 @@
1
+ import type { Texture } from "three/webgpu";
2
+ export interface SurfaceRole {
3
+ /** Library name after "style.ps1.". */
4
+ readonly id: string;
5
+ /** Metres one repeat of the texture covers. */
6
+ readonly metres: number;
7
+ /** Fallback: base colour and speckle colours of the procedural texture. */
8
+ readonly base: string;
9
+ readonly speckle: readonly string[];
10
+ }
11
+ /**
12
+ * The roles templates tag. Aliases map the words a template is likely to
13
+ * use onto the same texture (a "floor" in a yard is concrete).
14
+ */
15
+ export declare const SURFACES: Readonly<Record<string, SurfaceRole>>;
16
+ /** The role a tag names, or null. */
17
+ export declare function surfaceRole(tag: string): SurfaceRole | null;
18
+ /** A muted copy of an image (saturation and contrast pulled in), or null when it cannot be read. */
19
+ export declare function muted(img: unknown): HTMLCanvasElement | null;
20
+ /** Linear RGB. */
21
+ export type Rgb = readonly [number, number, number];
22
+ /** Mean linear colour of an sRGB image (drawn down to 8x8), or null when it cannot be read. */
23
+ export declare function imageAverage(img: unknown): Rgb | null;
24
+ export declare function matchTint(want: Rgb, avg: Rgb): Rgb;
25
+ /** A role's texture, its repeat, and its mean colour (now, and again when the library file arrives). */
26
+ export interface SurfaceTexture {
27
+ readonly texture: Texture;
28
+ readonly metres: number;
29
+ onAverage(cb: (avg: Rgb) => void): void;
30
+ }
31
+ /**
32
+ * Loads and caches one texture per role. `resolve` maps a library id to a
33
+ * URL (or null when there is no library), from the template's LibraryClient.
34
+ */
35
+ export declare class Ps1Surfaces {
36
+ private readonly resolve;
37
+ private readonly cache;
38
+ private readonly averages;
39
+ private readonly listeners;
40
+ private readonly loader;
41
+ constructor(resolve: ((id: string) => string | null) | undefined);
42
+ get(tag: string): SurfaceTexture | null;
43
+ dispose(): void;
44
+ }