incanto 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 (138) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.md +88 -0
  4. package/assets/audio/attacked.mp3 +0 -0
  5. package/assets/audio/explosion.mp3 +0 -0
  6. package/assets/audio/gold_loot.mp3 +0 -0
  7. package/assets/audio/heal.mp3 +0 -0
  8. package/assets/audio/hit_metal_bang.mp3 +0 -0
  9. package/assets/audio/ice_spear.mp3 +0 -0
  10. package/assets/audio/monster_died.mp3 +0 -0
  11. package/assets/audio/slash.mp3 +0 -0
  12. package/assets/audio/smite.mp3 +0 -0
  13. package/assets/audio/spells_cast.mp3 +0 -0
  14. package/assets/audio/ui_click.wav +0 -0
  15. package/assets/audio/walk.mp3 +0 -0
  16. package/assets/catalog.json +390 -0
  17. package/assets/characters/2dbasic.json +41 -0
  18. package/assets/characters/2dbasic.png +0 -0
  19. package/assets/characters/ghost.json +46 -0
  20. package/assets/characters/ghost.png +0 -0
  21. package/assets/characters/goblin.json +40 -0
  22. package/assets/characters/goblin.png +0 -0
  23. package/assets/characters/medieval-knight.json +41 -0
  24. package/assets/characters/medieval-knight.png +0 -0
  25. package/assets/effects/swoosh.png +0 -0
  26. package/assets/items/box.png +0 -0
  27. package/assets/items/buff_potion.png +0 -0
  28. package/assets/items/coin.png +0 -0
  29. package/assets/items/gem.png +0 -0
  30. package/assets/items/gold.png +0 -0
  31. package/assets/items/hp_potion.png +0 -0
  32. package/assets/items/locked_item_box.png +0 -0
  33. package/assets/items/map.png +0 -0
  34. package/assets/items/resurrection_potion.png +0 -0
  35. package/assets/items/super_box.png +0 -0
  36. package/assets/items/trap.png +0 -0
  37. package/assets/tiles/floor00.jpg +0 -0
  38. package/assets/tiles/minecraft-tiles.png +0 -0
  39. package/assets/tiles/wall00.jpg +0 -0
  40. package/assets/vegetation/ash_color.png +0 -0
  41. package/assets/vegetation/aspen_color.png +0 -0
  42. package/assets/vegetation/bark/birch_color_1k.jpg +0 -0
  43. package/assets/vegetation/bark/birch_normal_1k.jpg +0 -0
  44. package/assets/vegetation/bark/birch_roughness_1k.jpg +0 -0
  45. package/assets/vegetation/bark/oak_color_1k.jpg +0 -0
  46. package/assets/vegetation/bark/oak_normal_1k.jpg +0 -0
  47. package/assets/vegetation/bark/oak_roughness_1k.jpg +0 -0
  48. package/assets/vegetation/bark/pine_color_1k.jpg +0 -0
  49. package/assets/vegetation/bark/pine_normal_1k.jpg +0 -0
  50. package/assets/vegetation/bark/pine_roughness_1k.jpg +0 -0
  51. package/assets/vegetation/ground/dirt_color.jpg +0 -0
  52. package/assets/vegetation/ground/dirt_normal.jpg +0 -0
  53. package/assets/vegetation/ground/grass.jpg +0 -0
  54. package/assets/vegetation/oak_color.png +0 -0
  55. package/assets/vegetation/pine_color.png +0 -0
  56. package/bin/incanto-assets.mjs +107 -0
  57. package/bin/incanto-check.mjs +107 -0
  58. package/bin/incanto-editor.mjs +343 -0
  59. package/bin/incanto-env.mjs +144 -0
  60. package/bin/incanto-model.mjs +296 -0
  61. package/bin/incanto-play.mjs +219 -0
  62. package/bin/incanto-skills.mjs +71 -0
  63. package/dist/2d.d.ts +642 -0
  64. package/dist/2d.js +44 -0
  65. package/dist/3d.d.ts +1860 -0
  66. package/dist/3d.js +5 -0
  67. package/dist/agent8-DzU2fFyH.js +129 -0
  68. package/dist/audio-player-DqUR3XFs.d.ts +110 -0
  69. package/dist/behavior-BAQq7HGM.d.ts +851 -0
  70. package/dist/create-game-BdjpTHrW.js +1725 -0
  71. package/dist/create-game-CZHROKcT.js +527 -0
  72. package/dist/debug-draw-CZmOYjL2.js +13 -0
  73. package/dist/debug.d.ts +66 -0
  74. package/dist/debug.js +658 -0
  75. package/dist/duplicate-DP2WPYom.js +22 -0
  76. package/dist/env.d.ts +430 -0
  77. package/dist/env.js +3152 -0
  78. package/dist/errors-BMFaY68Q.d.ts +33 -0
  79. package/dist/errors-BpWbnbb_.js +13 -0
  80. package/dist/gameplay-Ccruc3Wd.js +1501 -0
  81. package/dist/gameplay.d.ts +543 -0
  82. package/dist/gameplay.js +2 -0
  83. package/dist/heightmap-CroQPEER.js +185 -0
  84. package/dist/index.d.ts +305 -0
  85. package/dist/index.js +62 -0
  86. package/dist/json-BLk7H2Qa.js +30 -0
  87. package/dist/loader-CGs_G-r0.js +919 -0
  88. package/dist/loader-Mo0KghCv.d.ts +41 -0
  89. package/dist/net.d.ts +427 -0
  90. package/dist/net.js +772 -0
  91. package/dist/noise-CGUMx44x.js +82 -0
  92. package/dist/particle-sim-CbN4YUuH.d.ts +63 -0
  93. package/dist/particle-sim-DYuSUxvK.js +1319 -0
  94. package/dist/physics-2d-KuMWPTf6.js +288 -0
  95. package/dist/physics-3d-Dl67vOLT.js +434 -0
  96. package/dist/react.d.ts +65 -0
  97. package/dist/react.js +209 -0
  98. package/dist/register-BuUV1_KB.js +561 -0
  99. package/dist/register-CNlYAS1_.js +10634 -0
  100. package/dist/register-DPEV9_9t.js +851 -0
  101. package/dist/register-Dasmnurl.js +374 -0
  102. package/dist/registry-BVJ2HbCn.js +132 -0
  103. package/dist/rng-DP-SR7eg.js +38 -0
  104. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  105. package/dist/schema-CcoWb32N.d.ts +104 -0
  106. package/dist/test.d.ts +158 -0
  107. package/dist/test.js +275 -0
  108. package/dist/touch-031PxtCR.js +208 -0
  109. package/dist/vite.d.ts +26 -0
  110. package/dist/vite.js +57 -0
  111. package/editor/assets/GameServer-C56iOUgF.js +1 -0
  112. package/editor/assets/agent8-Bp7QFI7v.js +1 -0
  113. package/editor/assets/index-DF3tMeKJ.css +1 -0
  114. package/editor/assets/index-Dl2pjA8e.js +7365 -0
  115. package/editor/assets/rapier-CEuLKeCu.js +1 -0
  116. package/editor/assets/rapier-DE6a0vmv.js +1 -0
  117. package/editor/index.html +169 -0
  118. package/package.json +97 -0
  119. package/schemas/scene.schema.json +4254 -0
  120. package/skills/README.md +9 -0
  121. package/skills/incanto-3d-character.md +229 -0
  122. package/skills/incanto-3d-models.md +151 -0
  123. package/skills/incanto-assets.md +118 -0
  124. package/skills/incanto-audio.md +309 -0
  125. package/skills/incanto-behaviors-and-scripts.md +169 -0
  126. package/skills/incanto-building-2d-games.md +242 -0
  127. package/skills/incanto-building-3d-games.md +245 -0
  128. package/skills/incanto-editor.md +163 -0
  129. package/skills/incanto-environment.md +743 -0
  130. package/skills/incanto-gameplay-behaviors.md +707 -0
  131. package/skills/incanto-multiplayer.md +264 -0
  132. package/skills/incanto-node-reference.md +797 -0
  133. package/skills/incanto-physics-and-input.md +164 -0
  134. package/skills/incanto-scene-json-authoring.md +325 -0
  135. package/skills/incanto-verifying-your-game.md +191 -0
  136. package/skills/incanto-web-integration.md +96 -0
  137. package/templates/agent8-server.js +84 -0
  138. package/templates/agent8-server.ts +138 -0
@@ -0,0 +1,1725 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
+ import { _ as registerBehavior, n as loadScene } from "./loader-CGs_G-r0.js";
3
+ import { a as Engine } from "./particle-sim-DYuSUxvK.js";
4
+ import { t as IncantoError } from "./errors-BpWbnbb_.js";
5
+ import { r as AudioPlayer } from "./register-BuUV1_KB.js";
6
+ import { i as resolveRendering, n as attachTouchControls } from "./touch-031PxtCR.js";
7
+ import { n as registerGameplayBehaviors } from "./gameplay-Ccruc3Wd.js";
8
+ import { t as debugSources } from "./debug-draw-CZmOYjL2.js";
9
+ import { O as Node3D, T as PhysicsBody3D, c as ModelInstance3D, t as registerNodes3D, u as DirectionalLight3D, v as Camera3D } from "./register-CNlYAS1_.js";
10
+ import { n as enablePhysics3D } from "./physics-3d-Dl67vOLT.js";
11
+ import { ACESFilmicToneMapping, AmbientLight, BufferAttribute, BufferGeometry, Color, DepthTexture, EquirectangularReflectionMapping, FloatType, Fog, LineBasicMaterial, LineSegments, Matrix4, Mesh, PCFShadowMap, PMREMGenerator, PerspectiveCamera, PlaneGeometry, Quaternion, Raycaster, Scene, ShaderMaterial, Vector2, Vector3, WebGLRenderTarget, WebGLRenderer } from "three";
12
+ import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
13
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
14
+ import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
15
+ import { Sky } from "three/examples/jsm/objects/Sky.js";
16
+ //#region src/3d/assets.ts
17
+ /**
18
+ * GLB/glTF/VRM store for the 3D adapter. Scene `assets` entries:
19
+ *
20
+ * {type:"model", url} → ModelInstance3D `model: "$key"`
21
+ * {type:"animation", url, clip?} → ModelInstance3D `animation: "$key"`
22
+ * (clips live in memory, never drawn)
23
+ *
24
+ * Raw URLs also work anywhere a `$key` does. VRM files load through
25
+ * @pixiv/three-vrm (VRM 0.x facing fixed via rotateVRM0).
26
+ */
27
+ var AssetStore3D = class {
28
+ models = /* @__PURE__ */ new Map();
29
+ animations = /* @__PURE__ */ new Map();
30
+ modelKeys = /* @__PURE__ */ new Map();
31
+ animationKeys = /* @__PURE__ */ new Map();
32
+ loader;
33
+ constructor() {
34
+ this.loader = new GLTFLoader();
35
+ this.loader.register((parser) => new VRMLoaderPlugin(parser));
36
+ }
37
+ /** Map a scene's `assets` header. */
38
+ load(assets) {
39
+ for (const [key, def] of Object.entries(assets)) {
40
+ const entry = def;
41
+ if (typeof entry.url !== "string") continue;
42
+ if (entry.type === "model") this.modelKeys.set(`$${key}`, entry.url);
43
+ else if (entry.type === "animation") this.animationKeys.set(`$${key}`, {
44
+ url: entry.url,
45
+ clip: entry.clip
46
+ });
47
+ }
48
+ }
49
+ /** Animation `$key`s declared by the scene (editor dropdowns). */
50
+ animationRefs() {
51
+ return [...this.animationKeys.keys()];
52
+ }
53
+ modelRefs() {
54
+ return [...this.modelKeys.keys()];
55
+ }
56
+ /** Resolve `$key` or URL; kicks off the load on first sight. */
57
+ getModel(ref) {
58
+ const url = this.modelKeys.get(ref) ?? ref;
59
+ let entry = this.models.get(url);
60
+ if (entry) return entry;
61
+ entry = {
62
+ status: "loading",
63
+ scene: null,
64
+ animations: [],
65
+ isVrm: false,
66
+ vrm: null,
67
+ claimedBy: null,
68
+ retargeted: /* @__PURE__ */ new Map()
69
+ };
70
+ this.models.set(url, entry);
71
+ this.loader.load(url, (gltf) => {
72
+ const vrm = gltf.userData.vrm;
73
+ if (vrm) {
74
+ VRMUtils.removeUnnecessaryVertices(gltf.scene);
75
+ VRMUtils.combineSkeletons(gltf.scene);
76
+ VRMUtils.rotateVRM0(vrm);
77
+ entry.scene = vrm.scene;
78
+ entry.vrm = vrm;
79
+ entry.isVrm = true;
80
+ } else entry.scene = gltf.scene;
81
+ entry.animations = gltf.animations ?? [];
82
+ entry.status = "ready";
83
+ }, void 0, (error) => {
84
+ entry.status = "error";
85
+ entry.error = error instanceof Error ? error.message : String(error);
86
+ console.error(`incanto: failed to load model '${url}':`, error);
87
+ });
88
+ return entry;
89
+ }
90
+ /** Resolve an animation `$key` or URL; clips load into memory only. */
91
+ getAnimation(ref) {
92
+ const def = this.animationKeys.get(ref) ?? { url: ref };
93
+ let entry = this.animations.get(def.url);
94
+ if (entry) return entry;
95
+ entry = {
96
+ status: "loading",
97
+ clips: [],
98
+ scene: null,
99
+ clip: def.clip
100
+ };
101
+ this.animations.set(def.url, entry);
102
+ this.loader.load(def.url, (gltf) => {
103
+ entry.clips = gltf.animations ?? [];
104
+ entry.scene = gltf.scene;
105
+ entry.status = "ready";
106
+ if (entry.clips.length === 0) console.warn(`incanto: animation asset '${def.url}' contains no clips`);
107
+ }, void 0, (error) => {
108
+ entry.status = "error";
109
+ entry.error = error instanceof Error ? error.message : String(error);
110
+ console.error(`incanto: failed to load animation '${def.url}':`, error);
111
+ });
112
+ return entry;
113
+ }
114
+ dispose() {
115
+ for (const entry of this.models.values()) entry.scene?.traverse((obj) => {
116
+ const mesh = obj;
117
+ mesh.geometry?.dispose();
118
+ const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
119
+ for (const mat of mats) {
120
+ if (!mat) continue;
121
+ for (const value of Object.values(mat)) value?.isTexture && value.dispose();
122
+ mat.dispose();
123
+ }
124
+ });
125
+ this.models.clear();
126
+ this.animations.clear();
127
+ }
128
+ };
129
+ //#endregion
130
+ //#region src/3d/clouds/clouds-composite.ts
131
+ /**
132
+ * Volumetric clouds — a raymarched cloud DECK the camera flies through, drawn as
133
+ * ONE depth-aware fullscreen composite (the engine has no post pipeline, so this
134
+ * mirrors the underwater-caustics pass). It runs only when `environment.clouds`
135
+ * is declared:
136
+ *
137
+ * 1. the renderer renders the scene to an offscreen target (color + depth)
138
+ * 2. this fullscreen material reconstructs each pixel's view RAY from the
139
+ * depth/inverse-view-proj, marches it through a world-space altitude slab
140
+ * [uBase, uTop], accumulates fractal-noise cloud density with cheap
141
+ * single-scatter sun lighting, and composites the result OVER the scene —
142
+ * clipped to the scene depth so clouds behind solid geometry are hidden.
143
+ *
144
+ * Because the clouds live in world space (noise sampled at the marched world
145
+ * position), they have real parallax: they sit still as you bank, grow as you
146
+ * approach, and swallow the screen as you punch through them.
147
+ */
148
+ const CLOUDS_VERT = `
149
+ varying vec2 vUv;
150
+ void main() {
151
+ vUv = uv;
152
+ gl_Position = vec4(position.xy, 0.0, 1.0);
153
+ }
154
+ `;
155
+ const CLOUDS_FRAG = `
156
+ precision highp float;
157
+ varying vec2 vUv;
158
+ uniform sampler2D tDepth;
159
+ uniform mat4 uInvViewProj;
160
+ uniform vec3 uCameraPos;
161
+ uniform float uTime;
162
+ uniform vec3 uSunDir; // unit, toward the sun
163
+ uniform vec3 uSunColor;
164
+ uniform vec3 uCloudColor; // sunlit
165
+ uniform vec3 uShadeColor; // shaded underside
166
+ uniform vec3 uHorizonColor; // distant haze the clouds fade into
167
+ uniform float uBase;
168
+ uniform float uTop;
169
+ uniform float uCoverage; // 0..1
170
+ uniform float uDensity; // optical thickness multiplier
171
+ uniform float uScale; // feature size (world units)
172
+ uniform vec2 uWind; // drift dir * speed
173
+ uniform float uFarFade; // distance over which clouds melt into haze
174
+
175
+ // ---- value noise + FBM (hash-based, no texture) ----------------------------------
176
+ float hash(vec3 p) {
177
+ p = fract(p * 0.3183099 + 0.1);
178
+ p *= 17.0;
179
+ return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
180
+ }
181
+ float vnoise(vec3 x) {
182
+ vec3 i = floor(x);
183
+ vec3 f = fract(x);
184
+ f = f * f * (3.0 - 2.0 * f);
185
+ return mix(
186
+ mix(mix(hash(i + vec3(0,0,0)), hash(i + vec3(1,0,0)), f.x),
187
+ mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
188
+ mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
189
+ mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y),
190
+ f.z);
191
+ }
192
+ float fbm(vec3 p) {
193
+ float s = 0.0;
194
+ float a = 0.5;
195
+ for (int i = 0; i < 3; i++) {
196
+ s += a * vnoise(p);
197
+ p = p * 2.04 + vec3(11.7, 3.1, 5.3);
198
+ a *= 0.5;
199
+ }
200
+ return s;
201
+ }
202
+
203
+ // vertical envelope: wispy base + top, dense middle → cumulus, not a flat slab
204
+ float cloudEnv(vec3 wp) {
205
+ float h = clamp((wp.y - uBase) / (uTop - uBase), 0.0, 1.0);
206
+ return smoothstep(0.0, 0.18, h) * smoothstep(1.0, 0.55, h);
207
+ }
208
+
209
+ // COARSE density (base FBM only) — used for the sun light-march, which doesn't
210
+ // need the fine erosion detail, so each lit step stays cheap (one fbm).
211
+ float cloudDensityCoarse(vec3 wp) {
212
+ float env = cloudEnv(wp);
213
+ if (env <= 0.0) return 0.0;
214
+ vec3 q = wp / uScale;
215
+ q.xz += uWind * uTime * 0.03;
216
+ return max(0.0, fbm(q) - (1.0 - uCoverage)) * env * 2.0;
217
+ }
218
+
219
+ // FULL density: coarse shape eroded by two higher-frequency layers → crisp
220
+ // billowy cauliflower edges instead of soft amorphous blobs.
221
+ float cloudDensity(vec3 wp) {
222
+ float env = cloudEnv(wp);
223
+ if (env <= 0.0) return 0.0;
224
+ vec3 q = wp / uScale;
225
+ q.xz += uWind * uTime * 0.03;
226
+ float d = fbm(q) - (1.0 - uCoverage);
227
+ if (d <= 0.0) return 0.0; // empty space — skip the erosion noise (cheap)
228
+ d -= 0.30 * vnoise(q * 3.2 + 4.0);
229
+ d -= 0.14 * vnoise(q * 8.5 + 9.0);
230
+ return max(0.0, d) * env * 2.0;
231
+ }
232
+
233
+ void main() {
234
+ float depth = texture2D(tDepth, vUv).x;
235
+
236
+ // view ray (world space) for this pixel
237
+ vec4 farNdc = vec4(vUv * 2.0 - 1.0, 1.0, 1.0);
238
+ vec4 farW = uInvViewProj * farNdc;
239
+ farW.xyz /= farW.w;
240
+ vec3 ro = uCameraPos;
241
+ vec3 rd = normalize(farW.xyz - ro);
242
+
243
+ // distance to the nearest solid surface along the ray (sky = far away)
244
+ float sceneDist = 1e9;
245
+ if (depth < 1.0) {
246
+ vec4 ndc = vec4(vUv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
247
+ vec4 wp = uInvViewProj * ndc;
248
+ wp.xyz /= wp.w;
249
+ sceneDist = distance(wp.xyz, ro);
250
+ }
251
+
252
+ // intersect the ray with the altitude slab [uBase, uTop]
253
+ float t0 = 0.0;
254
+ float t1 = uFarFade * 1.6;
255
+ if (abs(rd.y) > 1e-4) {
256
+ float ta = (uBase - ro.y) / rd.y;
257
+ float tb = (uTop - ro.y) / rd.y;
258
+ t0 = max(t0, min(ta, tb));
259
+ t1 = min(t1, max(ta, tb));
260
+ } else {
261
+ // ray nearly parallel to the slab: only valid if we're inside it
262
+ if (ro.y < uBase || ro.y > uTop) { gl_FragColor = vec4(0.0); return; }
263
+ }
264
+ t1 = min(t1, sceneDist);
265
+ if (t1 <= t0) { gl_FragColor = vec4(0.0); return; }
266
+
267
+ // raymarch (capped step count; clamp the marched span so steps stay dense)
268
+ // Break step-aliasing rings with INTERLEAVED GRADIENT NOISE (IGN) rather than a
269
+ // Bayer matrix (which upscaled into a regular halftone GRID) or no dither (which
270
+ // left banding RINGS). IGN is high-quality de-correlated noise that, softened by
271
+ // the composite blur, dissolves into smooth cloud — affordable at a modest step
272
+ // count instead of brute-forcing steps (which blew the frame budget).
273
+ const int STEPS = 32;
274
+ float span = min(t1 - t0, 2000.0);
275
+ float step = span / float(STEPS);
276
+ float ign = fract(52.9829189 * fract(0.06711056 * gl_FragCoord.x + 0.00583715 * gl_FragCoord.y));
277
+ float t = t0 + step * ign;
278
+ float trans = 1.0;
279
+ vec3 accum = vec3(0.0);
280
+ vec3 litColor = mix(vec3(1.0), uSunColor, 0.35) * uCloudColor; // mostly-white
281
+ // EXT = per-metre extinction. step is in metres, so without this the optical
282
+ // depth per step is enormous (one step → solid white blobs). ~0.03/m + the
283
+ // uDensity dial gives clouds that build up gradually and keep their FORM.
284
+ float ext = 0.03 * uDensity;
285
+ for (int i = 0; i < STEPS; i++) {
286
+ if (trans < 0.02) break;
287
+ vec3 p = ro + rd * t;
288
+ float d = cloudDensity(p);
289
+ if (d > 0.001) {
290
+ // single-scatter: ONE coarse tap toward the sun (cheap — keeps per-step cost
291
+ // low) → sunlit crests vs softly shaded sides. Clouds multi-scatter a LOT, so
292
+ // shadows are gentle (low extinction *1.5) and floored bright (0.45..1) — real
293
+ // cumulus from above are mostly white, not dark-grey blotches.
294
+ float sunTau = cloudDensityCoarse(p + uSunDir * 110.0) * 110.0 * ext;
295
+ float light = mix(0.45, 1.0, exp(-sunTau * 1.5));
296
+ vec3 col = mix(uShadeColor, litColor, light);
297
+ float a = d * ext * step; // optical depth this step
298
+ float w = (1.0 - exp(-a)) * trans; // energy scattered toward the eye
299
+ accum += col * w;
300
+ trans *= exp(-a);
301
+ }
302
+ t += step;
303
+ }
304
+
305
+ float alpha = clamp(1.0 - trans, 0.0, 1.0);
306
+ // Melt distant clouds fully into the horizon haze BEFORE they smear: looking
307
+ // edge-on through the thin slab, far samples streak — so fade the whole cloud
308
+ // contribution out over distance and drop alpha to 0 by the far edge.
309
+ float distFade = 1.0 - smoothstep(uFarFade * 0.25, uFarFade * 0.7, t0);
310
+ accum = mix(uHorizonColor * alpha, accum, distFade);
311
+ alpha *= distFade;
312
+
313
+ // output PREMULTIPLIED cloud color + coverage — composited over the scene in
314
+ // a separate full-res pass (this raymarch runs at HALF res for performance)
315
+ gl_FragColor = vec4(accum, alpha);
316
+ }
317
+ `;
318
+ /** Separable Gaussian blur on the low-res cloud buffer — run twice (H then V).
319
+ * Wide enough to fully dissolve the raymarch dither (incl. IGN's diagonal grain)
320
+ * into smooth cloud; cheap because it runs at the low cloud-buffer resolution. */
321
+ const CLOUDS_BLUR_FRAG = `
322
+ precision highp float;
323
+ varying vec2 vUv;
324
+ uniform sampler2D tTex;
325
+ uniform vec2 uDir; // (texel, 0) horizontal pass, then (0, texel) vertical
326
+ void main() {
327
+ // 9-tap Gaussian (sigma ~2.2 texels) — weights sum to 1
328
+ vec4 c = texture2D(tTex, vUv) * 0.20;
329
+ c += texture2D(tTex, vUv + uDir * 1.0) * 0.166;
330
+ c += texture2D(tTex, vUv - uDir * 1.0) * 0.166;
331
+ c += texture2D(tTex, vUv + uDir * 2.0) * 0.096;
332
+ c += texture2D(tTex, vUv - uDir * 2.0) * 0.096;
333
+ c += texture2D(tTex, vUv + uDir * 3.0) * 0.04;
334
+ c += texture2D(tTex, vUv - uDir * 3.0) * 0.04;
335
+ c += texture2D(tTex, vUv + uDir * 4.0) * 0.015;
336
+ c += texture2D(tTex, vUv - uDir * 4.0) * 0.015;
337
+ gl_FragColor = c;
338
+ }
339
+ `;
340
+ /** Composite pass: blend the (pre-blurred) low-res cloud buffer over the scene. */
341
+ const CLOUDS_COMPOSITE_FRAG = `
342
+ precision highp float;
343
+ varying vec2 vUv;
344
+ uniform sampler2D tScene;
345
+ uniform sampler2D tClouds;
346
+ void main() {
347
+ vec4 scene = texture2D(tScene, vUv);
348
+ vec4 cloud = texture2D(tClouds, vUv); // already separable-blurred + premultiplied
349
+ gl_FragColor = vec4(scene.rgb * (1.0 - cloud.a) + cloud.rgb, 1.0);
350
+ }
351
+ `;
352
+ /** Separable-blur quad (run twice: horizontal then vertical) over the low-res
353
+ * cloud buffer — set `uDir` to (1/w, 0) then (0, 1/h). */
354
+ function createCloudsBlurQuad() {
355
+ const material = new ShaderMaterial({
356
+ vertexShader: CLOUDS_VERT,
357
+ fragmentShader: CLOUDS_BLUR_FRAG,
358
+ depthTest: false,
359
+ depthWrite: false,
360
+ uniforms: {
361
+ tTex: { value: null },
362
+ uDir: { value: new Vector2() }
363
+ }
364
+ });
365
+ const mesh = new Mesh(new PlaneGeometry(2, 2), material);
366
+ mesh.frustumCulled = false;
367
+ return {
368
+ mesh,
369
+ uniforms: material.uniforms
370
+ };
371
+ }
372
+ /** The half-res raymarch quad — writes premultiplied cloud color + coverage. */
373
+ function createCloudsQuad() {
374
+ const material = new ShaderMaterial({
375
+ vertexShader: CLOUDS_VERT,
376
+ fragmentShader: CLOUDS_FRAG,
377
+ depthTest: false,
378
+ depthWrite: false,
379
+ uniforms: {
380
+ tDepth: { value: null },
381
+ uInvViewProj: { value: new Matrix4() },
382
+ uCameraPos: { value: new Vector3() },
383
+ uTime: { value: 0 },
384
+ uSunDir: { value: new Vector3(0, 1, 0) },
385
+ uSunColor: { value: new Color("#fff3da") },
386
+ uCloudColor: { value: new Color("#ffffff") },
387
+ uShadeColor: { value: new Color("#9fb0c8") },
388
+ uHorizonColor: { value: new Color("#cdd9e6") },
389
+ uBase: { value: 120 },
390
+ uTop: { value: 320 },
391
+ uCoverage: { value: .5 },
392
+ uDensity: { value: 1 },
393
+ uScale: { value: 240 },
394
+ uWind: { value: new Vector2(1, .3) },
395
+ uFarFade: { value: 6e3 }
396
+ }
397
+ });
398
+ const mesh = new Mesh(new PlaneGeometry(2, 2), material);
399
+ mesh.frustumCulled = false;
400
+ return {
401
+ mesh,
402
+ uniforms: material.uniforms
403
+ };
404
+ }
405
+ /** The full-res composite quad — blends the half-res cloud buffer over the scene. */
406
+ function createCloudsCompositeQuad() {
407
+ const material = new ShaderMaterial({
408
+ vertexShader: CLOUDS_VERT,
409
+ fragmentShader: CLOUDS_COMPOSITE_FRAG,
410
+ depthTest: false,
411
+ depthWrite: false,
412
+ uniforms: {
413
+ tScene: { value: null },
414
+ tClouds: { value: null }
415
+ }
416
+ });
417
+ const mesh = new Mesh(new PlaneGeometry(2, 2), material);
418
+ mesh.frustumCulled = false;
419
+ return {
420
+ mesh,
421
+ uniforms: material.uniforms
422
+ };
423
+ }
424
+ //#endregion
425
+ //#region src/3d/environment.ts
426
+ const SKY_KEYS = [
427
+ "type",
428
+ "sunPosition",
429
+ "elevationDeg",
430
+ "azimuthDeg",
431
+ "turbidity",
432
+ "rayleigh"
433
+ ];
434
+ const FOG_KEYS = [
435
+ "color",
436
+ "near",
437
+ "far"
438
+ ];
439
+ const CLOUD_KEYS = [
440
+ "coverage",
441
+ "density",
442
+ "base",
443
+ "top",
444
+ "color",
445
+ "shadeColor",
446
+ "speed",
447
+ "scale"
448
+ ];
449
+ const SHADOW_KEYS = ["mapSize", "radius"];
450
+ const SHADOW_MAP_SIZES = [1024, 2048];
451
+ const DEFAULT_TURBIDITY = 2;
452
+ const DEFAULT_RAYLEIGH = 1;
453
+ const DEFAULT_FOG_NEAR = 50;
454
+ const DEFAULT_FOG_FAR = 800;
455
+ /** Fallback haze when fog is declared without a sky to derive a horizon from. */
456
+ const DEFAULT_FOG_COLOR = "#cfd8e0";
457
+ const DEG2RAD = Math.PI / 180;
458
+ /** Parse + hard-validate the 3D slice of a scene's `environment` header. */
459
+ function parseEnvironment3D(env) {
460
+ return {
461
+ exposure: parseExposure(env?.exposure),
462
+ sky: parseSky(env?.sky),
463
+ fog: parseFog(env?.fog, env?.sky !== void 0),
464
+ clouds: parseClouds(env?.clouds),
465
+ shadows: parseShadows(env?.shadows)
466
+ };
467
+ }
468
+ /**
469
+ * elevation/azimuth (degrees) → unit direction. Matches three's Sky example
470
+ * (`Vector3.setFromSphericalCoords`): azimuth 0 = +Z, 90 = +X; elevation 90 =
471
+ * straight up.
472
+ */
473
+ function sunDirectionFromElevationAzimuth(elevationDeg, azimuthDeg) {
474
+ const phi = (90 - elevationDeg) * DEG2RAD;
475
+ const theta = azimuthDeg * DEG2RAD;
476
+ return [
477
+ Math.sin(phi) * Math.sin(theta),
478
+ Math.cos(phi),
479
+ Math.sin(phi) * Math.cos(theta)
480
+ ];
481
+ }
482
+ /** The sky's sun as a UNIT vector — what Water3D/Foliage3D uniforms consume. */
483
+ function sunDirectionFromSky(sky) {
484
+ const [x, y, z] = sky.sunPosition;
485
+ const len = Math.hypot(x, y, z) || 1;
486
+ return [
487
+ x / len,
488
+ y / len,
489
+ z / len
490
+ ];
491
+ }
492
+ /**
493
+ * A horizon-ish haze color derived from the sky config — the default fog
494
+ * color. Cheap model, judged by eye: clear skies haze blue-grey, turbid skies
495
+ * whiten, and a low sun warms the band toward amber.
496
+ */
497
+ function horizonColorFromSky(sky) {
498
+ const clear = [
499
+ 191,
500
+ 213,
501
+ 232
502
+ ];
503
+ const hazy = [
504
+ 233,
505
+ 228,
506
+ 217
507
+ ];
508
+ const warm = [
509
+ 242,
510
+ 201,
511
+ 150
512
+ ];
513
+ const t = clamp01((sky.turbidity - DEFAULT_TURBIDITY) / 8);
514
+ const dir = sunDirectionFromSky(sky);
515
+ const w = clamp01((18 - Math.asin(clamp(dir[1], -1, 1)) / DEG2RAD) / 18) * .8;
516
+ const mix = (i) => Math.round(lerp(lerp(clear[i], hazy[i], t), warm[i], w));
517
+ return `#${[
518
+ mix(0),
519
+ mix(1),
520
+ mix(2)
521
+ ].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
522
+ }
523
+ function parseExposure(value) {
524
+ if (value === void 0) return 1;
525
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new IncantoError("BAD_FORMAT", `environment.exposure must be a finite number > 0 (tone-mapping exposure, default 1), got ${JSON.stringify(value)}.`, { prop: "exposure" });
526
+ return value;
527
+ }
528
+ function parseSky(value) {
529
+ if (value === void 0) return null;
530
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.sky must be an object ({ type?: "atmosphere", sunPosition? | elevationDeg?+azimuthDeg?, turbidity?, rayleigh? }), got ${JSON.stringify(value)}.`, { prop: "sky" });
531
+ const sky = value;
532
+ for (const key of Object.keys(sky)) if (!SKY_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.sky has unknown key '${key}'. Valid keys: [${SKY_KEYS.join(", ")}].`, {
533
+ prop: "sky",
534
+ validOptions: SKY_KEYS
535
+ });
536
+ if (sky.type !== void 0 && sky.type !== "atmosphere") throw new IncantoError("BAD_FORMAT", `environment.sky.type must be 'atmosphere' (the only sky type so far), got ${JSON.stringify(sky.type)}.`, {
537
+ prop: "sky",
538
+ validOptions: ["atmosphere"]
539
+ });
540
+ const hasAngles = sky.elevationDeg !== void 0 || sky.azimuthDeg !== void 0;
541
+ if (sky.sunPosition !== void 0 && hasAngles) throw new IncantoError("BAD_FORMAT", `environment.sky takes sunPosition OR elevationDeg/azimuthDeg, not both.`, {
542
+ prop: "sky",
543
+ validOptions: ["sunPosition", "elevationDeg+azimuthDeg"]
544
+ });
545
+ let sunPosition;
546
+ if (sky.sunPosition !== void 0) {
547
+ const sp = sky.sunPosition;
548
+ if (!Array.isArray(sp) || sp.length !== 3 || !sp.every((v) => typeof v === "number" && Number.isFinite(v)) || Math.hypot(sp[0], sp[1], sp[2]) === 0) throw new IncantoError("BAD_FORMAT", `environment.sky.sunPosition must be a non-zero [x, y, z] vector, got ${JSON.stringify(sp)}.`, { prop: "sky" });
549
+ sunPosition = [
550
+ sp[0],
551
+ sp[1],
552
+ sp[2]
553
+ ];
554
+ } else {
555
+ const elevation = numberOr(sky.elevationDeg, 32, "sky.elevationDeg");
556
+ const azimuth = numberOr(sky.azimuthDeg, 135, "sky.azimuthDeg");
557
+ if (elevation < -90 || elevation > 90) throw new IncantoError("BAD_FORMAT", `environment.sky.elevationDeg must be in [-90, 90] (degrees above the horizon), got ${elevation}.`, { prop: "sky" });
558
+ sunPosition = sunDirectionFromElevationAzimuth(elevation, azimuth);
559
+ }
560
+ const turbidity = numberOr(sky.turbidity, DEFAULT_TURBIDITY, "sky.turbidity");
561
+ if (turbidity <= 0) throw new IncantoError("BAD_FORMAT", `environment.sky.turbidity must be > 0 (atmospheric haze; 2 ≈ clear day), got ${turbidity}.`, { prop: "sky" });
562
+ const rayleigh = numberOr(sky.rayleigh, DEFAULT_RAYLEIGH, "sky.rayleigh");
563
+ if (rayleigh < 0) throw new IncantoError("BAD_FORMAT", `environment.sky.rayleigh must be >= 0 (Rayleigh scattering; 1 ≈ earth-like), got ${rayleigh}.`, { prop: "sky" });
564
+ return {
565
+ sunPosition,
566
+ turbidity,
567
+ rayleigh
568
+ };
569
+ }
570
+ function parseFog(value, hasSky) {
571
+ if (value === void 0) return null;
572
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.fog must be an object ({ color?, near?, far? }), got ${JSON.stringify(value)}.`, { prop: "fog" });
573
+ const fog = value;
574
+ for (const key of Object.keys(fog)) if (!FOG_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.fog has unknown key '${key}'. Valid keys: [${FOG_KEYS.join(", ")}].`, {
575
+ prop: "fog",
576
+ validOptions: FOG_KEYS
577
+ });
578
+ if (fog.color !== void 0 && typeof fog.color !== "string") throw new IncantoError("BAD_FORMAT", `environment.fog.color must be a hex color string, got ${JSON.stringify(fog.color)}.`, { prop: "fog" });
579
+ const near = numberOr(fog.near, DEFAULT_FOG_NEAR, "fog.near");
580
+ const far = numberOr(fog.far, DEFAULT_FOG_FAR, "fog.far");
581
+ if (near < 0) throw new IncantoError("BAD_FORMAT", `environment.fog.near must be >= 0 meters, got ${near}.`, { prop: "fog" });
582
+ if (far <= near) throw new IncantoError("BAD_FORMAT", `environment.fog.far must be > near (got near ${near}, far ${far}).`, { prop: "fog" });
583
+ return {
584
+ color: fog.color ?? (hasSky ? "" : DEFAULT_FOG_COLOR),
585
+ near,
586
+ far
587
+ };
588
+ }
589
+ function parseClouds(value) {
590
+ if (value === void 0) return null;
591
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.clouds must be an object ({ coverage?, density?, base?, top?, color?, shadeColor?, speed?, scale? }), got ${JSON.stringify(value)}.`, { prop: "clouds" });
592
+ const c = value;
593
+ for (const key of Object.keys(c)) if (!CLOUD_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.clouds has unknown key '${key}'. Valid keys: [${CLOUD_KEYS.join(", ")}].`, {
594
+ prop: "clouds",
595
+ validOptions: CLOUD_KEYS
596
+ });
597
+ for (const k of ["color", "shadeColor"]) if (c[k] !== void 0 && typeof c[k] !== "string") throw new IncantoError("BAD_FORMAT", `environment.clouds.${k} must be a hex color string, got ${JSON.stringify(c[k])}.`, { prop: "clouds" });
598
+ const coverage = numberOr(c.coverage, .5, "clouds.coverage");
599
+ if (coverage < 0 || coverage > 1) throw new IncantoError("BAD_FORMAT", `environment.clouds.coverage must be in [0, 1] (how much sky is cloudy), got ${coverage}.`, { prop: "clouds" });
600
+ const density = numberOr(c.density, 1, "clouds.density");
601
+ if (density < 0) throw new IncantoError("BAD_FORMAT", `environment.clouds.density must be >= 0 (optical thickness), got ${density}.`, { prop: "clouds" });
602
+ const base = numberOr(c.base, 120, "clouds.base");
603
+ const top = numberOr(c.top, 320, "clouds.top");
604
+ if (top <= base) throw new IncantoError("BAD_FORMAT", `environment.clouds.top must be > base (got base ${base}, top ${top}).`, { prop: "clouds" });
605
+ const speed = numberOr(c.speed, 1, "clouds.speed");
606
+ const scale = numberOr(c.scale, 240, "clouds.scale");
607
+ if (scale <= 0) throw new IncantoError("BAD_FORMAT", `environment.clouds.scale must be > 0 (feature size in world units), got ${scale}.`, { prop: "clouds" });
608
+ return {
609
+ coverage,
610
+ density,
611
+ base,
612
+ top,
613
+ color: c.color ?? "#ffffff",
614
+ shadeColor: c.shadeColor ?? "#9fb0c8",
615
+ speed,
616
+ scale
617
+ };
618
+ }
619
+ function parseShadows(value) {
620
+ if (value === void 0) return null;
621
+ if (value === false) return false;
622
+ if (value === true) return {
623
+ mapSize: 2048,
624
+ radius: 1
625
+ };
626
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.shadows must be true, false or an object ({ mapSize?, radius? }), got ${JSON.stringify(value)}.`, { prop: "shadows" });
627
+ const shadows = value;
628
+ for (const key of Object.keys(shadows)) if (!SHADOW_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.shadows has unknown key '${key}'. Valid keys: [${SHADOW_KEYS.join(", ")}].`, {
629
+ prop: "shadows",
630
+ validOptions: SHADOW_KEYS
631
+ });
632
+ const mapSize = shadows.mapSize === void 0 ? 2048 : shadows.mapSize;
633
+ if (!SHADOW_MAP_SIZES.includes(mapSize)) throw new IncantoError("BAD_FORMAT", `environment.shadows.mapSize must be one of [${SHADOW_MAP_SIZES.join(", ")}], got ${JSON.stringify(shadows.mapSize)}.`, {
634
+ prop: "shadows",
635
+ validOptions: SHADOW_MAP_SIZES.map(String)
636
+ });
637
+ const radius = numberOr(shadows.radius, 1, "shadows.radius");
638
+ if (radius < 0) throw new IncantoError("BAD_FORMAT", `environment.shadows.radius must be >= 0, got ${radius}.`, { prop: "shadows" });
639
+ return {
640
+ mapSize,
641
+ radius
642
+ };
643
+ }
644
+ function numberOr(value, fallback, at) {
645
+ if (value === void 0) return fallback;
646
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new IncantoError("BAD_FORMAT", `environment.${at} must be a finite number, got ${JSON.stringify(value)}.`, { prop: at });
647
+ return value;
648
+ }
649
+ function clamp(v, min, max) {
650
+ return Math.min(Math.max(v, min), max);
651
+ }
652
+ function clamp01(v) {
653
+ return clamp(v, 0, 1);
654
+ }
655
+ function lerp(a, b, t) {
656
+ return a + (b - a) * t;
657
+ }
658
+ //#endregion
659
+ //#region src/3d/environment-presets.ts
660
+ /**
661
+ * drei-compatible environment presets: each name maps to a 1k HDRI (the exact
662
+ * Poly Haven CC0 files @react-three/drei ships), so `preset="sunset"` lights the
663
+ * same here. Served from the agent8 CDN (the only sanctioned external host, same
664
+ * as the terrain splat) — set `environment.hdri` to override with your own URL.
665
+ */
666
+ const CDN = "https://agent8-games.verse8.io/assets/3D/default/textures/hdri";
667
+ const ENVIRONMENT_PRESETS = {
668
+ apartment: "lebombo_1k.hdr",
669
+ city: "potsdamer_platz_1k.hdr",
670
+ dawn: "kiara_1_dawn_1k.hdr",
671
+ forest: "forest_slope_1k.hdr",
672
+ lobby: "st_fagans_interior_1k.hdr",
673
+ night: "dikhololo_night_1k.hdr",
674
+ park: "rooitou_park_1k.hdr",
675
+ studio: "studio_small_03_1k.hdr",
676
+ sunset: "venice_sunset_1k.hdr",
677
+ warehouse: "empty_warehouse_01_1k.hdr"
678
+ };
679
+ /** Resolve `environment.preset` / `environment.hdri` to the HDR url (or null). */
680
+ function resolveEnvironmentHdri(env) {
681
+ if (typeof env.hdri === "string" && env.hdri !== "") return env.hdri;
682
+ if (typeof env.preset === "string") {
683
+ const file = ENVIRONMENT_PRESETS[env.preset];
684
+ if (!file) throw new Error(`Unknown environment preset '${env.preset}'. Available: ${Object.keys(ENVIRONMENT_PRESETS).join(", ")}.`);
685
+ return `${CDN}/${file}`;
686
+ }
687
+ return null;
688
+ }
689
+ //#endregion
690
+ //#region src/3d/environment-3d.ts
691
+ /** Shadow ortho half-extent when the env header forces a light to cast and the
692
+ * light node configured nothing itself. One static box around the origin — no
693
+ * cascaded shadow maps yet, so huge worlds trade shadow sharpness for reach. */
694
+ const DEFAULT_SHADOW_AREA = 75;
695
+ /**
696
+ * Applies the scene `environment` header to a three scene — the rendering
697
+ * stage Renderer3D delegates to. One instance per renderer; `apply` runs once
698
+ * per frame but re-parses/rebuilds only when the env JSON actually changed.
699
+ *
700
+ * Headless-safe: `gl` is nullable, and everything GPU-bound (PMREM
701
+ * image-based lighting, exposure, shadow-map switches) is skipped without it
702
+ * while the scene-graph side (Sky object, fog, background, ambient) still
703
+ * applies — that's what the node-environment tests assert against.
704
+ */
705
+ var Environment3D = class {
706
+ scene;
707
+ ambient = new AmbientLight("#ffffff", 0);
708
+ envKey = null;
709
+ config = parseEnvironment3D(void 0);
710
+ hdriUrl = null;
711
+ hdriTexture = null;
712
+ /** @internal The Sky backdrop object (tests reach in). */
713
+ _sky = null;
714
+ skyKey = "";
715
+ skyEnvKey = "";
716
+ skyEnvTarget = null;
717
+ fog = new Fog("#ffffff", 1, 1e3);
718
+ /** Underwater fog/tint, created lazily the first time the camera submerges. */
719
+ underwaterFog = null;
720
+ underwaterBg = new Color();
721
+ constructor(scene) {
722
+ this.scene = scene;
723
+ this.scene.add(this.ambient);
724
+ }
725
+ /** The sky's unit sun direction, or null when no sky is declared. */
726
+ get sunDirection() {
727
+ return this.config.sky ? sunDirectionFromSky(this.config.sky) : null;
728
+ }
729
+ /** Parsed volumetric-cloud config, or null when no cloud layer is declared. */
730
+ get clouds() {
731
+ return this.config.clouds;
732
+ }
733
+ /** The live scene fog (color/near/far) the cloud composite fades into, or null. */
734
+ get sceneFog() {
735
+ return this.scene.fog instanceof Fog ? this.scene.fog : null;
736
+ }
737
+ /** Apply the env header. Cheap when unchanged; hard-validates on change. */
738
+ apply(env, gl) {
739
+ const key = env === void 0 ? "" : JSON.stringify(env);
740
+ if (key !== this.envKey) {
741
+ this.envKey = key;
742
+ this.config = parseEnvironment3D(env);
743
+ }
744
+ const cfg = this.config;
745
+ const ambient = env?.ambient;
746
+ this.ambient.color.set(ambient?.color ?? "#ffffff");
747
+ this.ambient.intensity = ambient?.intensity ?? 0;
748
+ if (gl) {
749
+ gl.toneMappingExposure = cfg.exposure;
750
+ gl.shadowMap.enabled = cfg.shadows !== false;
751
+ }
752
+ this.applyHdri(env);
753
+ this.applySky(cfg.sky, gl);
754
+ if (this._sky) this._sky.visible = true;
755
+ this.applyFog(cfg);
756
+ this.scene.environment = this.hdriTexture ?? this.skyEnvTarget?.texture ?? null;
757
+ this.scene.environmentIntensity = (this.scene.environment === this.skyEnvTarget?.texture && this.skyEnvTarget ? .55 : 1) * parseIblIntensity(env?.iblIntensity);
758
+ if (env?.skybox === true && this.hdriTexture) this.scene.background = this.hdriTexture;
759
+ else {
760
+ const background = env?.background;
761
+ this.scene.background = typeof background === "string" ? new Color(background) : null;
762
+ }
763
+ }
764
+ /**
765
+ * Per-frame hand-off for the scene's MAIN DirectionalLight (the renderer
766
+ * passes the brightest one after the sync pass — never auto-created):
767
+ *
768
+ * - sky declared → the light's backing object is re-aimed along the sun
769
+ * direction (its distance from origin is kept, so the node's `position`
770
+ * keeps meaning "how far out the sun sits")
771
+ * - `shadows` declared truthy → the light casts; env mapSize/radius win,
772
+ * and lights that configured nothing themselves get the default ortho box
773
+ */
774
+ applySunLight(light, focus = null) {
775
+ if (!light) return;
776
+ const sun = this.sunDirection;
777
+ if (focus) {
778
+ const distance = light.position.length() || 100;
779
+ let dx;
780
+ let dy;
781
+ let dz;
782
+ if (sun) [dx, dy, dz] = sun;
783
+ else {
784
+ dx = light.position.x - light.target.position.x;
785
+ dy = light.position.y - light.target.position.y;
786
+ dz = light.position.z - light.target.position.z;
787
+ const len = Math.hypot(dx, dy, dz) || 1;
788
+ dx /= len;
789
+ dy /= len;
790
+ dz /= len;
791
+ }
792
+ light.target.position.set(focus.x, focus.y, focus.z);
793
+ light.target.updateMatrixWorld();
794
+ light.position.set(focus.x + dx * distance, focus.y + dy * distance, focus.z + dz * distance);
795
+ } else if (sun) {
796
+ const distance = light.position.length() || 100;
797
+ light.position.set(sun[0], sun[1], sun[2]).multiplyScalar(distance);
798
+ }
799
+ const shadows = this.config.shadows;
800
+ if (shadows && typeof shadows === "object") {
801
+ if (!light.castShadow) {
802
+ const cam = light.shadow.camera;
803
+ if ("left" in cam && cam.left === -5) {
804
+ cam.left = -75;
805
+ cam.right = DEFAULT_SHADOW_AREA;
806
+ cam.top = DEFAULT_SHADOW_AREA;
807
+ cam.bottom = -75;
808
+ cam.near = .5;
809
+ cam.far = 500;
810
+ cam.updateProjectionMatrix();
811
+ }
812
+ light.castShadow = true;
813
+ }
814
+ if (light.shadow.mapSize.x !== shadows.mapSize) {
815
+ light.shadow.mapSize.set(shadows.mapSize, shadows.mapSize);
816
+ light.shadow.map?.dispose();
817
+ light.shadow.map = null;
818
+ }
819
+ light.shadow.radius = shadows.radius;
820
+ }
821
+ }
822
+ /**
823
+ * Underwater override (the renderer passes the resolved config from whichever
824
+ * water the camera is submerged in, or null). When submerged, the scene
825
+ * switches to a short-range underwater fog + matching background AND the sky
826
+ * dome is hidden — without that you'd see the bright sky through the surface
827
+ * and the murk would never read. `null` is a no-op: the per-frame `apply`
828
+ * already (re)established the normal fog/background/sky, so surfacing restores
829
+ * the look for free.
830
+ */
831
+ applyUnderwater(config) {
832
+ if (!config) return;
833
+ if (!this.underwaterFog) this.underwaterFog = new Fog("#000000", .5, config.visibility);
834
+ this.underwaterFog.color.set(config.color);
835
+ this.underwaterFog.near = .5;
836
+ this.underwaterFog.far = config.visibility;
837
+ this.scene.fog = this.underwaterFog;
838
+ this.scene.background = this.underwaterBg.set(config.color);
839
+ if (this._sky) this._sky.visible = false;
840
+ }
841
+ applyHdri(env) {
842
+ const url = resolveEnvironmentHdri(env ?? {});
843
+ if (url === this.hdriUrl) return;
844
+ this.hdriUrl = url;
845
+ this.hdriTexture?.dispose();
846
+ this.hdriTexture = null;
847
+ if (url) new RGBELoader().load(url, (texture) => {
848
+ if (this.hdriUrl !== url) {
849
+ texture.dispose();
850
+ return;
851
+ }
852
+ texture.mapping = EquirectangularReflectionMapping;
853
+ this.hdriTexture = texture;
854
+ });
855
+ }
856
+ applySky(sky, gl) {
857
+ const skyKey = sky ? JSON.stringify(sky) : "";
858
+ if (skyKey !== this.skyKey) {
859
+ this.skyKey = skyKey;
860
+ if (this._sky) {
861
+ this.scene.remove(this._sky);
862
+ this._sky.material.dispose();
863
+ this._sky.geometry.dispose();
864
+ this._sky = null;
865
+ }
866
+ if (sky) {
867
+ this._sky = new Sky();
868
+ this._sky.scale.setScalar(45e4);
869
+ const u = this._sky.material.uniforms;
870
+ (u.sunPosition?.value).set(...sky.sunPosition);
871
+ if (u.turbidity) u.turbidity.value = sky.turbidity;
872
+ if (u.rayleigh) u.rayleigh.value = sky.rayleigh;
873
+ this.scene.add(this._sky);
874
+ }
875
+ }
876
+ if (sky && gl && this._sky && this.skyEnvKey !== skyKey) {
877
+ this.skyEnvKey = skyKey;
878
+ this.skyEnvTarget?.dispose();
879
+ const pmrem = new PMREMGenerator(gl);
880
+ const skyOnly = new Scene();
881
+ skyOnly.add(this._sky);
882
+ this.skyEnvTarget = pmrem.fromScene(skyOnly);
883
+ pmrem.dispose();
884
+ this.scene.add(this._sky);
885
+ }
886
+ if (!sky && this.skyEnvTarget) {
887
+ this.skyEnvTarget.dispose();
888
+ this.skyEnvTarget = null;
889
+ this.skyEnvKey = "";
890
+ }
891
+ }
892
+ applyFog(cfg) {
893
+ if (!cfg.fog) {
894
+ this.scene.fog = null;
895
+ return;
896
+ }
897
+ const color = cfg.fog.color || (cfg.sky ? horizonColorFromSky(cfg.sky) : "#cfd8e0");
898
+ this.fog.color.set(color);
899
+ this.fog.near = cfg.fog.near;
900
+ this.fog.far = cfg.fog.far;
901
+ this.scene.fog = this.fog;
902
+ }
903
+ dispose() {
904
+ this.hdriTexture?.dispose();
905
+ this.hdriTexture = null;
906
+ this.skyEnvTarget?.dispose();
907
+ this.skyEnvTarget = null;
908
+ if (this._sky) {
909
+ this.scene.remove(this._sky);
910
+ this._sky.material.dispose();
911
+ this._sky.geometry.dispose();
912
+ this._sky = null;
913
+ }
914
+ }
915
+ };
916
+ /** Hard-validate `environment.iblIntensity` (default 1 = keep the base). */
917
+ function parseIblIntensity(value) {
918
+ if (value === void 0) return 1;
919
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new IncantoError("BAD_FORMAT", `environment.iblIntensity must be a finite number >= 0 (multiplies the image-based ambience: sky-derived base 0.55, HDRI base 1; default 1), got ${JSON.stringify(value)}.`, { prop: "iblIntensity" });
920
+ return value;
921
+ }
922
+ //#endregion
923
+ //#region src/3d/sync.ts
924
+ function isSpatialConsumer(node) {
925
+ return node.spatial === true && typeof node._setSpatialPose === "function";
926
+ }
927
+ function createSyncScratch() {
928
+ const visited = /* @__PURE__ */ new Set();
929
+ const cameras = [];
930
+ const renderHooks = [];
931
+ const emitters = [];
932
+ return {
933
+ visited,
934
+ cameras,
935
+ renderHooks,
936
+ emitters,
937
+ state: {
938
+ visited,
939
+ cameras,
940
+ renderHooks,
941
+ emitters,
942
+ assets: void 0,
943
+ sunDirection: null,
944
+ sunLight: null,
945
+ alpha: 1
946
+ }
947
+ };
948
+ }
949
+ /**
950
+ * Mirror an Incanto node tree onto a three.js scene (dirty-push, once per frame):
951
+ *
952
+ * - every Node3D's backing Object3D is parented under its nearest Node3D
953
+ * ancestor's object (plain Nodes are transparent), falling back to the scene
954
+ * - transforms/props are pushed via `_syncObject3D`
955
+ * - backing objects whose nodes left the tree are pruned
956
+ * - returns the active camera (`current: true` wins, else first in tree order)
957
+ *
958
+ * Pure scene-graph math — no WebGL — so it tests headlessly with real three.
959
+ */
960
+ function syncTree(root, threeScene, assets, opts, scratch) {
961
+ const s = scratch ?? createSyncScratch();
962
+ s.visited.clear();
963
+ s.cameras.length = 0;
964
+ s.renderHooks.length = 0;
965
+ s.emitters.length = 0;
966
+ const state = s.state;
967
+ state.assets = assets;
968
+ state.sunDirection = opts?.sunDirection ?? null;
969
+ state.sunLight = null;
970
+ state.alpha = opts?.alpha ?? 1;
971
+ walk(root, threeScene, state);
972
+ prune(threeScene, s.visited);
973
+ let current = null;
974
+ for (let i = 0; i < s.cameras.length; i++) {
975
+ const c = s.cameras[i];
976
+ if (c.current) {
977
+ current = c;
978
+ break;
979
+ }
980
+ }
981
+ if (!current) current = s.cameras[0] ?? null;
982
+ const activeCamera = current ? current._ensureObject3D() : null;
983
+ feedSpatialEmitters(s.emitters, activeCamera);
984
+ return {
985
+ activeCamera,
986
+ renderHooks: s.renderHooks,
987
+ sunLight: state.sunLight
988
+ };
989
+ }
990
+ const emitterScratch = new Vector3();
991
+ const listenerScratch = new Vector3();
992
+ const forwardScratch = new Vector3();
993
+ const upScratch = new Vector3();
994
+ /**
995
+ * Push each spatial AudioPlayer the live listener (active camera) pose + its own
996
+ * world position, so the WebAudio panner / src distance-attenuation tracks the
997
+ * scene each frame. Runs AFTER the walk so matrices are current and the active
998
+ * camera is known. With no camera there's no listener → skip (headless-safe).
999
+ */
1000
+ function feedSpatialEmitters(emitters, camera) {
1001
+ if (emitters.length === 0 || !camera) return;
1002
+ camera.updateWorldMatrix(true, false);
1003
+ camera.getWorldPosition(listenerScratch);
1004
+ camera.getWorldDirection(forwardScratch);
1005
+ upScratch.set(0, 1, 0).applyQuaternion(camera.quaternion);
1006
+ const listener = {
1007
+ position: [
1008
+ listenerScratch.x,
1009
+ listenerScratch.y,
1010
+ listenerScratch.z
1011
+ ],
1012
+ forward: [
1013
+ forwardScratch.x,
1014
+ forwardScratch.y,
1015
+ forwardScratch.z
1016
+ ],
1017
+ up: [
1018
+ upScratch.x,
1019
+ upScratch.y,
1020
+ upScratch.z
1021
+ ]
1022
+ };
1023
+ for (const { node, parent } of emitters) {
1024
+ parent.updateWorldMatrix(true, false);
1025
+ parent.getWorldPosition(emitterScratch);
1026
+ node._setSpatialPose({
1027
+ position: [
1028
+ emitterScratch.x,
1029
+ emitterScratch.y,
1030
+ emitterScratch.z
1031
+ ],
1032
+ listener
1033
+ });
1034
+ }
1035
+ }
1036
+ function walk(node, parentObj, state) {
1037
+ let nextParent = parentObj;
1038
+ if (node instanceof Node3D) {
1039
+ const obj = node._ensureObject3D();
1040
+ if (obj.parent !== parentObj) parentObj.add(obj);
1041
+ node._syncObject3D(state.alpha);
1042
+ if (state.assets && node instanceof ModelInstance3D) node._syncModel(state.assets);
1043
+ if (typeof node._onRender3D === "function") state.renderHooks.push(node);
1044
+ if (state.sunDirection) {
1045
+ const applySun = node._applySunDirection;
1046
+ if (typeof applySun === "function") applySun.call(node, state.sunDirection);
1047
+ }
1048
+ if (node instanceof DirectionalLight3D) {
1049
+ if (!state.sunLight || node.intensity > state.sunLight.intensity) state.sunLight = node;
1050
+ }
1051
+ state.visited.add(obj);
1052
+ if (node instanceof Camera3D) state.cameras.push(node);
1053
+ nextParent = obj;
1054
+ } else if (isSpatialConsumer(node)) state.emitters.push({
1055
+ node,
1056
+ parent: nextParent
1057
+ });
1058
+ for (const child of node.children) walk(child, nextParent, state);
1059
+ }
1060
+ function prune(obj, visited) {
1061
+ const ch = obj.children;
1062
+ for (let i = ch.length - 1; i >= 0; i--) {
1063
+ const child = ch[i];
1064
+ if (child.userData.incantoNode && !visited.has(child)) obj.remove(child);
1065
+ else prune(child, visited);
1066
+ }
1067
+ }
1068
+ //#endregion
1069
+ //#region src/3d/water/caustics.ts
1070
+ /**
1071
+ * Underwater caustics — the dancing refracted-light pattern the water surface
1072
+ * throws onto everything below it. The engine has no post-processing pipeline,
1073
+ * so the renderer does this as ONE depth-aware composite pass, and ONLY while
1074
+ * the camera is genuinely underwater (so normal above-water rendering — even
1075
+ * standing at the water's edge — is completely untouched):
1076
+ *
1077
+ * 1. render the scene to an offscreen target (color + depth)
1078
+ * 2. draw this fullscreen material, which reconstructs each pixel's WORLD
1079
+ * position from the depth buffer and adds an animated caustic highlight
1080
+ * wherever that world point sits below the water surface
1081
+ *
1082
+ * Because the pattern is keyed to reconstructed world XZ, it STICKS to the
1083
+ * floor/walls/props (it isn't a flat screen overlay) and fades with depth +
1084
+ * view distance so it melts into the underwater fog.
1085
+ */
1086
+ /** Fullscreen triangle/quad — the vertex stage ignores the camera entirely. */
1087
+ const CAUSTICS_VERT = `
1088
+ varying vec2 vUv;
1089
+ void main() {
1090
+ vUv = uv;
1091
+ gl_Position = vec4(position.xy, 0.0, 1.0);
1092
+ }
1093
+ `;
1094
+ const CAUSTICS_FRAG = `
1095
+ precision highp float;
1096
+ varying vec2 vUv;
1097
+ uniform sampler2D tColor;
1098
+ uniform sampler2D tDepth;
1099
+ uniform mat4 uInvViewProj;
1100
+ uniform vec3 uCameraPos;
1101
+ uniform float uWaterLevel;
1102
+ uniform float uTime;
1103
+ uniform vec3 uCausticColor;
1104
+ uniform float uCausticIntensity;
1105
+ uniform float uCausticScale;
1106
+ uniform float uMaxDist;
1107
+
1108
+ // The classic animated caustic (layered moving cells). Returns 0..1.
1109
+ float caustic(vec2 uv, float t) {
1110
+ vec2 p = mod(uv * 6.28318, 6.28318) - 250.0;
1111
+ vec2 i = p;
1112
+ float c = 1.0;
1113
+ float inten = 0.005;
1114
+ for (int n = 0; n < 5; n++) {
1115
+ float ti = t * (1.0 - (3.5 / float(n + 1)));
1116
+ i = p + vec2(cos(ti - i.x) + sin(ti + i.y), sin(ti - i.y) + cos(ti + i.x));
1117
+ c += 1.0 / length(vec2(p.x / (sin(i.x + ti) / inten), p.y / (cos(i.y + ti) / inten)));
1118
+ }
1119
+ c /= 5.0;
1120
+ c = 1.17 - pow(c, 1.4);
1121
+ return clamp(pow(abs(c), 8.0), 0.0, 1.0);
1122
+ }
1123
+
1124
+ void main() {
1125
+ vec4 color = texture2D(tColor, vUv);
1126
+ float depth = texture2D(tDepth, vUv).x;
1127
+ // depth == 1 is the far plane (sky/background) — nothing to light there
1128
+ if (depth < 1.0) {
1129
+ vec4 ndc = vec4(vUv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
1130
+ vec4 world = uInvViewProj * ndc;
1131
+ world.xyz /= world.w;
1132
+ float below = uWaterLevel - world.y;
1133
+ if (below > 0.0) {
1134
+ float dist = distance(world.xyz, uCameraPos);
1135
+ // caustics — fade with distance (melt into the fog) + a touch with depth
1136
+ float distFade = 1.0 - clamp(dist / uMaxDist, 0.0, 1.0);
1137
+ float depthFade = 1.0 - clamp(below / (uMaxDist * 0.5), 0.0, 0.6);
1138
+ // two octaves at offset speeds = the shimmering interference of real caustics
1139
+ float c =
1140
+ caustic(world.xz * uCausticScale, uTime) * 0.65 +
1141
+ caustic(world.xz * uCausticScale * 1.7 + 30.0, uTime * 0.8) * 0.35;
1142
+ color.rgb += uCausticColor * (c * uCausticIntensity * distFade * distFade * depthFade);
1143
+ }
1144
+ }
1145
+ gl_FragColor = color;
1146
+ }
1147
+ `;
1148
+ /** Build the fullscreen composite mesh (a 2×2 clip-space quad). */
1149
+ function createCausticsQuad() {
1150
+ const material = new ShaderMaterial({
1151
+ vertexShader: CAUSTICS_VERT,
1152
+ fragmentShader: CAUSTICS_FRAG,
1153
+ depthTest: false,
1154
+ depthWrite: false,
1155
+ uniforms: {
1156
+ tColor: { value: null },
1157
+ tDepth: { value: null },
1158
+ uInvViewProj: { value: new Matrix4() },
1159
+ uCameraPos: { value: new Vector3() },
1160
+ uWaterLevel: { value: 0 },
1161
+ uTime: { value: 0 },
1162
+ uCausticColor: { value: new Color("#cdeeff") },
1163
+ uCausticIntensity: { value: .55 },
1164
+ uCausticScale: { value: .32 },
1165
+ uMaxDist: { value: 22 }
1166
+ }
1167
+ });
1168
+ const mesh = new Mesh(new PlaneGeometry(2, 2), material);
1169
+ mesh.frustumCulled = false;
1170
+ return {
1171
+ mesh,
1172
+ uniforms: material.uniforms
1173
+ };
1174
+ }
1175
+ //#endregion
1176
+ //#region src/3d/renderer.ts
1177
+ /**
1178
+ * WebGL presentation layer: subscribes to `engine.updated`, mirrors the active
1179
+ * scene's node tree onto a three.js scene, applies the scene `environment`
1180
+ * header (ambient/background/sky/fog/shadows/exposure — see Environment3D),
1181
+ * and renders with the current Camera3D under ACES filmic tone mapping.
1182
+ *
1183
+ * Keep this class thin — everything testable lives in `syncTree`, the node
1184
+ * classes and `Environment3D`; this file is the only place that touches WebGL.
1185
+ */
1186
+ var Renderer3D = class {
1187
+ /**
1188
+ * When set, the render uses THIS free camera instead of the scene's active
1189
+ * Camera3D — tooling (the scene editor) orbits/pans without touching scene
1190
+ * data. `null` restores normal camera behavior.
1191
+ */
1192
+ viewOverride = null;
1193
+ overrideCam = new PerspectiveCamera(60, 1, .05, 5e3);
1194
+ lastCamera = null;
1195
+ lastSize = {
1196
+ w: 1,
1197
+ h: 1
1198
+ };
1199
+ webgl;
1200
+ threeScene = new Scene();
1201
+ environment = new Environment3D(this.threeScene);
1202
+ engine;
1203
+ disconnect;
1204
+ canvas;
1205
+ assets;
1206
+ ownsAssets;
1207
+ loadedAssetScenes = /* @__PURE__ */ new WeakSet();
1208
+ /** The scene whose GPU programs have been pre-compiled (warm-up, see render). */
1209
+ compiledScene = null;
1210
+ /** Reused per-frame walk scratch (instance-scoped — never a module global). */
1211
+ syncScratch = createSyncScratch();
1212
+ /** Reused render-hook context — gl/scene are stable, camera reassigned/frame. */
1213
+ renderCtx = null;
1214
+ /** Underwater caustics pass (lazy — only created the first time submerged). */
1215
+ causticsTarget = null;
1216
+ causticsScene = null;
1217
+ causticsUniforms = null;
1218
+ /** Volumetric cloud pass (lazy — only created when environment.clouds is set).
1219
+ * The raymarch runs at HALF resolution into `cloudsHalfTarget`, then a cheap
1220
+ * full-res composite blends it over the scene — keeps the heavy fullscreen
1221
+ * raymarch off the critical path so it holds 60fps at retina. */
1222
+ cloudsTarget = null;
1223
+ cloudsHalfTarget = null;
1224
+ cloudsBlurTarget = null;
1225
+ cloudsScene = null;
1226
+ cloudsUniforms = null;
1227
+ cloudsBlurScene = null;
1228
+ cloudsBlurUniforms = null;
1229
+ cloudsCompositeScene = null;
1230
+ cloudsCompositeUniforms = null;
1231
+ constructor(opts) {
1232
+ this.canvas = opts.canvas;
1233
+ this.engine = opts.engine;
1234
+ this.ownsAssets = !opts.assets;
1235
+ this.assets = opts.assets ?? new AssetStore3D();
1236
+ const rendering = resolveRendering(opts.engine.scene?.environment, {
1237
+ antialias: true,
1238
+ pixelRatio: Math.min(globalThis.devicePixelRatio ?? 1, 2)
1239
+ }, globalThis.devicePixelRatio ?? 1, { pixelRatio: opts.pixelRatio });
1240
+ this.webgl = new WebGLRenderer({
1241
+ canvas: opts.canvas,
1242
+ antialias: rendering.antialias
1243
+ });
1244
+ this.webgl.setPixelRatio(rendering.pixelRatio);
1245
+ this.webgl.shadowMap.enabled = true;
1246
+ this.webgl.shadowMap.type = PCFShadowMap;
1247
+ this.webgl.toneMapping = ACESFilmicToneMapping;
1248
+ this.webgl.toneMappingExposure = 1;
1249
+ this.debugLines = new LineSegments(new BufferGeometry(), new LineBasicMaterial({
1250
+ color: "#00ff6e",
1251
+ depthTest: false
1252
+ }));
1253
+ this.debugLines.frustumCulled = false;
1254
+ this.debugLines.renderOrder = 9999;
1255
+ this.debugLines.visible = false;
1256
+ this.threeScene.add(this.debugLines);
1257
+ this.disconnect = this.engine.updated.connect(() => this.render());
1258
+ }
1259
+ debugLines;
1260
+ syncDebugLines() {
1261
+ let vertices = null;
1262
+ for (const source of debugSources("3d")) {
1263
+ vertices = source.debugLines();
1264
+ if (vertices) break;
1265
+ }
1266
+ this.debugLines.visible = vertices !== null;
1267
+ if (vertices) this.debugLines.geometry.setAttribute("position", new BufferAttribute(vertices, 3));
1268
+ }
1269
+ render() {
1270
+ const scene = this.engine.scene;
1271
+ if (!scene) return;
1272
+ this.syncDebugLines();
1273
+ if (scene.assets && !this.loadedAssetScenes.has(scene)) {
1274
+ this.assets.load(scene.assets);
1275
+ this.loadedAssetScenes.add(scene);
1276
+ }
1277
+ this.environment.apply(scene.environment, this.webgl);
1278
+ const { activeCamera: sceneCamera, renderHooks, sunLight } = syncTree(scene.root, this.threeScene, this.assets, {
1279
+ sunDirection: this.environment.sunDirection,
1280
+ alpha: this.engine.interpolationAlpha
1281
+ }, this.syncScratch);
1282
+ let activeCamera = sceneCamera;
1283
+ if (this.viewOverride) {
1284
+ const [px, py, pz] = this.viewOverride.position;
1285
+ const [tx, ty, tz] = this.viewOverride.target;
1286
+ this.overrideCam.position.set(px, py, pz);
1287
+ this.overrideCam.lookAt(lookScratch.set(tx, ty, tz));
1288
+ activeCamera = this.overrideCam;
1289
+ }
1290
+ if (!activeCamera) return;
1291
+ this.lastCamera = activeCamera;
1292
+ activeCamera.updateWorldMatrix(true, false);
1293
+ const camWorld = activeCamera.getWorldPosition(camPosScratch);
1294
+ const threeLight = sunLight ? sunLight._ensureObject3D() : null;
1295
+ const followsCamera = !!threeLight && sunLight.shadowFollowsCamera === true;
1296
+ this.environment.applySunLight(threeLight, followsCamera ? camWorld : null);
1297
+ if (this.compiledScene !== scene) {
1298
+ this.compiledScene = scene;
1299
+ this.webgl.compile(this.threeScene, activeCamera);
1300
+ }
1301
+ const w = this.canvas.clientWidth || this.canvas.width;
1302
+ const h = this.canvas.clientHeight || this.canvas.height;
1303
+ const size = this.webgl.getSize(sizeScratch);
1304
+ if (size.x !== w || size.y !== h) this.webgl.setSize(w, h, false);
1305
+ this.lastSize = {
1306
+ w,
1307
+ h
1308
+ };
1309
+ const aspect = h === 0 ? 1 : w / h;
1310
+ if (activeCamera.aspect !== aspect) {
1311
+ activeCamera.aspect = aspect;
1312
+ activeCamera.updateProjectionMatrix();
1313
+ }
1314
+ if (!this.renderCtx) this.renderCtx = {
1315
+ gl: this.webgl,
1316
+ scene: this.threeScene,
1317
+ camera: activeCamera
1318
+ };
1319
+ const ctx = this.renderCtx;
1320
+ ctx.camera = activeCamera;
1321
+ for (let i = 0; i < renderHooks.length; i++) renderHooks[i]?._onRender3D(ctx);
1322
+ let underwater = null;
1323
+ for (let i = 0; i < renderHooks.length; i++) {
1324
+ const uw = renderHooks[i].underwaterAt?.(camWorld.x, camWorld.y, camWorld.z);
1325
+ if (uw) {
1326
+ underwater = uw;
1327
+ break;
1328
+ }
1329
+ }
1330
+ this.environment.applyUnderwater(underwater);
1331
+ const clouds = this.environment.clouds;
1332
+ if (underwater?.caustics.enabled) this.renderWithCaustics(activeCamera, underwater);
1333
+ else if (clouds && !underwater) this.renderWithClouds(activeCamera, clouds, threeLight);
1334
+ else this.webgl.render(this.threeScene, activeCamera);
1335
+ }
1336
+ /**
1337
+ * Volumetric cloud pass (only when `environment.clouds` is declared — scenes
1338
+ * without it render exactly as before, zero cost). Three steps:
1339
+ * 1. scene → full-res offscreen target (color + depth)
1340
+ * 2. raymarch the cloud slab → HALF-res target (the heavy fullscreen work,
1341
+ * run at ¼ the pixels so it holds 60fps even at retina)
1342
+ * 3. composite the half-res cloud buffer over the full-res scene → screen
1343
+ */
1344
+ renderWithClouds(camera, clouds, sunLight) {
1345
+ const buf = this.webgl.getDrawingBufferSize(sizeScratch);
1346
+ const w = Math.max(1, buf.x);
1347
+ const h = Math.max(1, buf.y);
1348
+ const hw = Math.max(1, Math.ceil(w / 3));
1349
+ const hh = Math.max(1, Math.ceil(h / 3));
1350
+ if (this.cloudsTarget && (this.cloudsTarget.width !== w || this.cloudsTarget.height !== h)) {
1351
+ this.cloudsTarget.depthTexture?.dispose();
1352
+ this.cloudsTarget.dispose();
1353
+ this.cloudsTarget = null;
1354
+ this.cloudsHalfTarget?.dispose();
1355
+ this.cloudsHalfTarget = null;
1356
+ this.cloudsBlurTarget?.dispose();
1357
+ this.cloudsBlurTarget = null;
1358
+ }
1359
+ if (!this.cloudsTarget) {
1360
+ const depthTexture = new DepthTexture(w, h);
1361
+ depthTexture.type = FloatType;
1362
+ this.cloudsTarget = new WebGLRenderTarget(w, h, {
1363
+ depthTexture,
1364
+ depthBuffer: true
1365
+ });
1366
+ }
1367
+ if (!this.cloudsHalfTarget) {
1368
+ this.cloudsHalfTarget = new WebGLRenderTarget(hw, hh, { depthBuffer: false });
1369
+ this.cloudsBlurTarget = new WebGLRenderTarget(hw, hh, { depthBuffer: false });
1370
+ }
1371
+ if (!this.cloudsScene) {
1372
+ const { mesh, uniforms } = createCloudsQuad();
1373
+ this.cloudsScene = new Scene();
1374
+ this.cloudsScene.add(mesh);
1375
+ this.cloudsUniforms = uniforms;
1376
+ }
1377
+ if (!this.cloudsBlurScene) {
1378
+ const { mesh, uniforms } = createCloudsBlurQuad();
1379
+ this.cloudsBlurScene = new Scene();
1380
+ this.cloudsBlurScene.add(mesh);
1381
+ this.cloudsBlurUniforms = uniforms;
1382
+ }
1383
+ if (!this.cloudsCompositeScene) {
1384
+ const { mesh, uniforms } = createCloudsCompositeQuad();
1385
+ this.cloudsCompositeScene = new Scene();
1386
+ this.cloudsCompositeScene.add(mesh);
1387
+ this.cloudsCompositeUniforms = uniforms;
1388
+ }
1389
+ this.webgl.setRenderTarget(this.cloudsTarget);
1390
+ this.webgl.render(this.threeScene, camera);
1391
+ const u = this.cloudsUniforms;
1392
+ u.tDepth.value = this.cloudsTarget.depthTexture;
1393
+ u.uInvViewProj.value.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse).invert();
1394
+ camera.getWorldPosition(u.uCameraPos.value);
1395
+ u.uTime.value = (globalThis.performance?.now() ?? 0) * .001 * clouds.speed;
1396
+ const sun = this.environment.sunDirection ?? [
1397
+ .4,
1398
+ .8,
1399
+ .3
1400
+ ];
1401
+ u.uSunDir.value.set(sun[0], sun[1], sun[2]).normalize();
1402
+ if (sunLight) u.uSunColor.value.copy(sunLight.color);
1403
+ u.uCloudColor.value.set(clouds.color);
1404
+ u.uShadeColor.value.set(clouds.shadeColor);
1405
+ const fog = this.environment.sceneFog;
1406
+ if (fog) {
1407
+ u.uHorizonColor.value.copy(fog.color);
1408
+ u.uFarFade.value = fog.far;
1409
+ } else {
1410
+ u.uHorizonColor.value.set("#cdd9e6");
1411
+ u.uFarFade.value = 6e3;
1412
+ }
1413
+ u.uBase.value = clouds.base;
1414
+ u.uTop.value = clouds.top;
1415
+ u.uCoverage.value = clouds.coverage;
1416
+ u.uDensity.value = clouds.density;
1417
+ u.uScale.value = clouds.scale;
1418
+ u.uWind.value.set(1, .35);
1419
+ this.webgl.setRenderTarget(this.cloudsHalfTarget);
1420
+ this.webgl.render(this.cloudsScene, camera);
1421
+ const bu = this.cloudsBlurUniforms;
1422
+ const blurScene = this.cloudsBlurScene;
1423
+ bu.tTex.value = this.cloudsHalfTarget.texture;
1424
+ bu.uDir.value.set(1 / hw, 0);
1425
+ this.webgl.setRenderTarget(this.cloudsBlurTarget);
1426
+ this.webgl.render(blurScene, camera);
1427
+ bu.tTex.value = this.cloudsBlurTarget.texture;
1428
+ bu.uDir.value.set(0, 1 / hh);
1429
+ this.webgl.setRenderTarget(this.cloudsHalfTarget);
1430
+ this.webgl.render(blurScene, camera);
1431
+ this.webgl.setRenderTarget(null);
1432
+ const cu = this.cloudsCompositeUniforms;
1433
+ cu.tScene.value = this.cloudsTarget.texture;
1434
+ cu.tClouds.value = this.cloudsHalfTarget.texture;
1435
+ this.webgl.render(this.cloudsCompositeScene, camera);
1436
+ }
1437
+ /**
1438
+ * Underwater caustics pass (only while submerged — normal rendering never
1439
+ * touches this). Render the scene to an offscreen color+depth target, then a
1440
+ * fullscreen composite that reconstructs each pixel's WORLD position from depth
1441
+ * and adds an animated caustic highlight below the water line. World-keyed, so
1442
+ * the pattern sticks to the floor/props instead of being a flat screen overlay.
1443
+ */
1444
+ renderWithCaustics(camera, underwater) {
1445
+ const buf = this.webgl.getDrawingBufferSize(sizeScratch);
1446
+ const w = Math.max(1, buf.x);
1447
+ const h = Math.max(1, buf.y);
1448
+ if (this.causticsTarget && (this.causticsTarget.width !== w || this.causticsTarget.height !== h)) {
1449
+ this.causticsTarget.depthTexture?.dispose();
1450
+ this.causticsTarget.dispose();
1451
+ this.causticsTarget = null;
1452
+ }
1453
+ if (!this.causticsTarget) {
1454
+ const depthTexture = new DepthTexture(w, h);
1455
+ depthTexture.type = FloatType;
1456
+ this.causticsTarget = new WebGLRenderTarget(w, h, {
1457
+ depthTexture,
1458
+ depthBuffer: true
1459
+ });
1460
+ }
1461
+ if (!this.causticsScene) {
1462
+ const { mesh, uniforms } = createCausticsQuad();
1463
+ this.causticsScene = new Scene();
1464
+ this.causticsScene.add(mesh);
1465
+ this.causticsUniforms = uniforms;
1466
+ }
1467
+ this.webgl.setRenderTarget(this.causticsTarget);
1468
+ this.webgl.render(this.threeScene, camera);
1469
+ this.webgl.setRenderTarget(null);
1470
+ const u = this.causticsUniforms;
1471
+ u.tColor.value = this.causticsTarget.texture;
1472
+ u.tDepth.value = this.causticsTarget.depthTexture;
1473
+ u.uInvViewProj.value.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse).invert();
1474
+ camera.getWorldPosition(u.uCameraPos.value);
1475
+ u.uWaterLevel.value = underwater.surfaceY;
1476
+ u.uTime.value = (globalThis.performance?.now() ?? 0) * .001 * underwater.caustics.speed;
1477
+ u.uCausticColor.value.set(underwater.caustics.color);
1478
+ u.uCausticIntensity.value = underwater.caustics.intensity;
1479
+ u.uCausticScale.value = underwater.caustics.scale;
1480
+ u.uMaxDist.value = underwater.visibility;
1481
+ this.webgl.render(this.causticsScene, camera);
1482
+ }
1483
+ /**
1484
+ * Project a world point through the LAST rendered camera to canvas CSS px.
1485
+ * `behind` is true when the point sits behind the camera (don't draw it).
1486
+ */
1487
+ screenFromWorld(wx, wy, wz) {
1488
+ const cam = this.lastCamera;
1489
+ if (!cam) return {
1490
+ x: 0,
1491
+ y: 0,
1492
+ behind: true
1493
+ };
1494
+ projScratch.set(wx, wy, wz).project(cam);
1495
+ return {
1496
+ x: (projScratch.x + 1) / 2 * this.lastSize.w,
1497
+ y: (1 - projScratch.y) / 2 * this.lastSize.h,
1498
+ behind: projScratch.z > 1
1499
+ };
1500
+ }
1501
+ /**
1502
+ * The node rendered under canvas pixel (sx, sy), or null — raycasts the last
1503
+ * rendered camera into the scene and resolves the hit up to its `incantoNode`
1504
+ * (every Node3D stamps that in `_ensureObject3D`). 3D depth-sorts, so the
1505
+ * nearest hit wins. Mirrors `Renderer2D.pick` for click-to-select in the editor.
1506
+ */
1507
+ pick(sx, sy) {
1508
+ const cam = this.lastCamera;
1509
+ if (!cam) return null;
1510
+ pickNdc.set(sx / this.lastSize.w * 2 - 1, -(sy / this.lastSize.h * 2 - 1));
1511
+ pickRaycaster.setFromCamera(pickNdc, cam);
1512
+ const hits = pickRaycaster.intersectObjects(this.threeScene.children, true);
1513
+ for (const hit of hits) {
1514
+ let obj = hit.object;
1515
+ while (obj && !obj.userData.incantoNode) obj = obj.parent;
1516
+ const node = obj?.userData.incantoNode;
1517
+ if (node) return node;
1518
+ }
1519
+ return null;
1520
+ }
1521
+ /**
1522
+ * GPU counters for the LAST rendered frame, straight from three's
1523
+ * per-frame-maintained `renderer.info` (render pass + GPU memory).
1524
+ */
1525
+ stats() {
1526
+ const info = this.webgl.info;
1527
+ return {
1528
+ triangles: info.render.triangles,
1529
+ drawCalls: info.render.calls,
1530
+ geometries: info.memory.geometries,
1531
+ textures: info.memory.textures
1532
+ };
1533
+ }
1534
+ /** The last rendered camera's world-space basis (for screen-plane dragging). */
1535
+ cameraBasis() {
1536
+ const cam = this.lastCamera;
1537
+ const q = cam ? cam.getWorldQuaternion(basisQuat) : basisQuat.identity();
1538
+ return {
1539
+ right: new Vector3(1, 0, 0).applyQuaternion(q),
1540
+ up: new Vector3(0, 1, 0).applyQuaternion(q),
1541
+ forward: new Vector3(0, 0, -1).applyQuaternion(q)
1542
+ };
1543
+ }
1544
+ dispose() {
1545
+ this.disconnect();
1546
+ this.threeScene.traverse((obj) => {
1547
+ const mesh = obj;
1548
+ if (!mesh.isMesh || mesh.userData?.incantoModelShared) return;
1549
+ mesh.geometry?.dispose();
1550
+ if (Array.isArray(mesh.material)) for (const m of mesh.material) m.dispose();
1551
+ else mesh.material?.dispose();
1552
+ });
1553
+ this.causticsTarget?.depthTexture?.dispose();
1554
+ this.causticsTarget?.dispose();
1555
+ const causticsMesh = this.causticsScene?.children[0];
1556
+ causticsMesh?.geometry?.dispose();
1557
+ causticsMesh?.material?.dispose();
1558
+ this.cloudsTarget?.depthTexture?.dispose();
1559
+ this.cloudsTarget?.dispose();
1560
+ this.cloudsHalfTarget?.dispose();
1561
+ this.cloudsBlurTarget?.dispose();
1562
+ for (const s of [
1563
+ this.cloudsScene,
1564
+ this.cloudsBlurScene,
1565
+ this.cloudsCompositeScene
1566
+ ]) {
1567
+ const m = s?.children[0];
1568
+ m?.geometry?.dispose();
1569
+ m?.material?.dispose();
1570
+ }
1571
+ if (this.ownsAssets) this.assets.dispose();
1572
+ this.environment.dispose();
1573
+ this.webgl.dispose();
1574
+ }
1575
+ };
1576
+ const sizeScratch = new Vector2();
1577
+ const lookScratch = new Vector3();
1578
+ const projScratch = new Vector3();
1579
+ const basisQuat = new Quaternion();
1580
+ /** Reused camera world-position scratch (shadow-follow focus + underwater test). */
1581
+ const camPosScratch = new Vector3();
1582
+ /** Reused click-pick scratches (no per-pick allocation). */
1583
+ const pickRaycaster = new Raycaster();
1584
+ const pickNdc = new Vector2();
1585
+ //#endregion
1586
+ //#region src/3d/create-game.ts
1587
+ var create_game_exports = /* @__PURE__ */ __exportAll({ createGame3D: () => createGame3D });
1588
+ /**
1589
+ * One-call 3D boot/teardown — the 3D twin of createGame2D: register → load
1590
+ * (engine attached for onReady) → physics (auto) → input → renderer → start.
1591
+ */
1592
+ async function createGame3D(opts) {
1593
+ registerNodes3D();
1594
+ if (opts.gameplay ?? true) registerGameplayBehaviors();
1595
+ for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
1596
+ const engine = new Engine({
1597
+ seed: opts.seed,
1598
+ fixedHz: opts.fixedHz,
1599
+ ...opts._scheduler ? { scheduler: opts._scheduler } : {}
1600
+ });
1601
+ const scene = loadScene(cloneSceneJson(opts.scene), {
1602
+ resolveScene: opts.resolveScene,
1603
+ engine
1604
+ });
1605
+ engine.setScene(scene);
1606
+ let physics = null;
1607
+ if (opts.physics === true || (opts.physics ?? "auto") === "auto" && hasBody(scene.root)) physics = await enablePhysics3D(engine);
1608
+ const cleanups = [];
1609
+ const keyboard = opts.keyboard ?? (typeof window !== "undefined" ? window : false);
1610
+ if (keyboard) engine.input.attachKeyboard(keyboard);
1611
+ if (opts.pointer) {
1612
+ const lockOnClick = typeof opts.pointer === "object" ? opts.pointer.lockOnClick ?? true : true;
1613
+ engine.input.attachPointer(opts.canvas, { lockOnClick });
1614
+ }
1615
+ cleanups.push(wireAudioUnlock(engine, scene.root, opts.canvas));
1616
+ const touchMode = opts.touch ?? "auto";
1617
+ if (touchMode !== false) {
1618
+ const host = opts.touchContainer ?? opts.canvas.parentElement ?? null;
1619
+ if (host) {
1620
+ const touchOpts = {
1621
+ force: touchMode === true,
1622
+ ...opts._touchDoc ? { doc: opts._touchDoc } : {}
1623
+ };
1624
+ let touchControls = attachTouchControls(engine, host, touchOpts);
1625
+ const offSceneChanged = engine.sceneChanged.connect(() => {
1626
+ touchControls?.dispose();
1627
+ touchControls = attachTouchControls(engine, host, touchOpts);
1628
+ });
1629
+ cleanups.push(() => {
1630
+ offSceneChanged();
1631
+ touchControls?.dispose();
1632
+ });
1633
+ }
1634
+ }
1635
+ const renderer = opts._rendererFactory ? opts._rendererFactory(engine, opts.canvas) : new Renderer3D({
1636
+ canvas: opts.canvas,
1637
+ engine,
1638
+ ...opts.pixelRatio !== void 0 ? { pixelRatio: opts.pixelRatio } : {}
1639
+ });
1640
+ if (opts.debug ?? false) {
1641
+ const host = opts.touchContainer ?? opts.canvas.parentElement ?? (typeof document !== "undefined" ? document.body : null);
1642
+ if (host) {
1643
+ const { attachDebugOverlay } = await import("./debug.js");
1644
+ const overlay = attachDebugOverlay(engine, {
1645
+ container: host,
1646
+ statsSource: () => renderer.stats?.() ?? {},
1647
+ ...opts._debugDoc ? { doc: opts._debugDoc } : {}
1648
+ });
1649
+ if (overlay) cleanups.push(() => overlay.dispose());
1650
+ }
1651
+ }
1652
+ engine.start();
1653
+ return {
1654
+ engine,
1655
+ scene,
1656
+ renderer,
1657
+ physics,
1658
+ stats() {
1659
+ return {
1660
+ triangles: 0,
1661
+ drawCalls: 0,
1662
+ geometries: 0,
1663
+ textures: 0,
1664
+ ...engine.stats(),
1665
+ ...renderer.stats?.()
1666
+ };
1667
+ },
1668
+ dispose() {
1669
+ renderer.dispose();
1670
+ physics?.dispose();
1671
+ for (const cleanup of cleanups) cleanup();
1672
+ engine.dispose();
1673
+ }
1674
+ };
1675
+ }
1676
+ function cloneSceneJson(json) {
1677
+ try {
1678
+ return structuredClone(json);
1679
+ } catch {
1680
+ throw new IncantoError("BAD_FORMAT", "createGame \"scene\" must be plain scene JSON (the imported *.scene.json object) — got something unclonable (a loaded Scene, class instances, or functions).");
1681
+ }
1682
+ }
1683
+ function hasBody(root) {
1684
+ if (root instanceof PhysicsBody3D) return true;
1685
+ for (const child of root.children) if (hasBody(child)) return true;
1686
+ return false;
1687
+ }
1688
+ /**
1689
+ * Browsers gate audio behind a user gesture — retry pending players on the
1690
+ * first pointer or key gesture, then stop listening once nothing is pending.
1691
+ */
1692
+ function wireAudioUnlock(engine, root, canvas) {
1693
+ const sources = [];
1694
+ const canvasTarget = canvas;
1695
+ if (canvasTarget.addEventListener) sources.push({
1696
+ target: canvasTarget,
1697
+ type: "pointerdown"
1698
+ });
1699
+ if (typeof window !== "undefined") sources.push({
1700
+ target: window,
1701
+ type: "keydown"
1702
+ });
1703
+ if (sources.length === 0) return () => {};
1704
+ function detachAll() {
1705
+ for (const s of sources) s.target.removeEventListener?.(s.type, unlock);
1706
+ }
1707
+ function unlock() {
1708
+ engine.sfx.unlock();
1709
+ engine.music.unlock();
1710
+ let pending = false;
1711
+ const walk = (node) => {
1712
+ if (node instanceof AudioPlayer) {
1713
+ node.retryPending();
1714
+ if (node.pendingGesture) pending = true;
1715
+ }
1716
+ for (const child of node.children) walk(child);
1717
+ };
1718
+ walk(root);
1719
+ if (!pending) detachAll();
1720
+ }
1721
+ for (const s of sources) s.target.addEventListener?.(s.type, unlock, { passive: true });
1722
+ return detachAll;
1723
+ }
1724
+ //#endregion
1725
+ export { Environment3D as a, sunDirectionFromElevationAzimuth as c, syncTree as i, sunDirectionFromSky as l, create_game_exports as n, horizonColorFromSky as o, Renderer3D as r, parseEnvironment3D as s, createGame3D as t, AssetStore3D as u };