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.
- package/LICENSE +30 -0
- package/README.md +36 -0
- package/THIRD-PARTY-NOTICES.md +88 -0
- package/assets/audio/attacked.mp3 +0 -0
- package/assets/audio/explosion.mp3 +0 -0
- package/assets/audio/gold_loot.mp3 +0 -0
- package/assets/audio/heal.mp3 +0 -0
- package/assets/audio/hit_metal_bang.mp3 +0 -0
- package/assets/audio/ice_spear.mp3 +0 -0
- package/assets/audio/monster_died.mp3 +0 -0
- package/assets/audio/slash.mp3 +0 -0
- package/assets/audio/smite.mp3 +0 -0
- package/assets/audio/spells_cast.mp3 +0 -0
- package/assets/audio/ui_click.wav +0 -0
- package/assets/audio/walk.mp3 +0 -0
- package/assets/catalog.json +390 -0
- package/assets/characters/2dbasic.json +41 -0
- package/assets/characters/2dbasic.png +0 -0
- package/assets/characters/ghost.json +46 -0
- package/assets/characters/ghost.png +0 -0
- package/assets/characters/goblin.json +40 -0
- package/assets/characters/goblin.png +0 -0
- package/assets/characters/medieval-knight.json +41 -0
- package/assets/characters/medieval-knight.png +0 -0
- package/assets/effects/swoosh.png +0 -0
- package/assets/items/box.png +0 -0
- package/assets/items/buff_potion.png +0 -0
- package/assets/items/coin.png +0 -0
- package/assets/items/gem.png +0 -0
- package/assets/items/gold.png +0 -0
- package/assets/items/hp_potion.png +0 -0
- package/assets/items/locked_item_box.png +0 -0
- package/assets/items/map.png +0 -0
- package/assets/items/resurrection_potion.png +0 -0
- package/assets/items/super_box.png +0 -0
- package/assets/items/trap.png +0 -0
- package/assets/tiles/floor00.jpg +0 -0
- package/assets/tiles/minecraft-tiles.png +0 -0
- package/assets/tiles/wall00.jpg +0 -0
- package/assets/vegetation/ash_color.png +0 -0
- package/assets/vegetation/aspen_color.png +0 -0
- package/assets/vegetation/bark/birch_color_1k.jpg +0 -0
- package/assets/vegetation/bark/birch_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/birch_roughness_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_color_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_roughness_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_color_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_roughness_1k.jpg +0 -0
- package/assets/vegetation/ground/dirt_color.jpg +0 -0
- package/assets/vegetation/ground/dirt_normal.jpg +0 -0
- package/assets/vegetation/ground/grass.jpg +0 -0
- package/assets/vegetation/oak_color.png +0 -0
- package/assets/vegetation/pine_color.png +0 -0
- package/bin/incanto-assets.mjs +107 -0
- package/bin/incanto-check.mjs +107 -0
- package/bin/incanto-editor.mjs +343 -0
- package/bin/incanto-env.mjs +144 -0
- package/bin/incanto-model.mjs +296 -0
- package/bin/incanto-play.mjs +219 -0
- package/bin/incanto-skills.mjs +71 -0
- package/dist/2d.d.ts +642 -0
- package/dist/2d.js +44 -0
- package/dist/3d.d.ts +1860 -0
- package/dist/3d.js +5 -0
- package/dist/agent8-DzU2fFyH.js +129 -0
- package/dist/audio-player-DqUR3XFs.d.ts +110 -0
- package/dist/behavior-BAQq7HGM.d.ts +851 -0
- package/dist/create-game-BdjpTHrW.js +1725 -0
- package/dist/create-game-CZHROKcT.js +527 -0
- package/dist/debug-draw-CZmOYjL2.js +13 -0
- package/dist/debug.d.ts +66 -0
- package/dist/debug.js +658 -0
- package/dist/duplicate-DP2WPYom.js +22 -0
- package/dist/env.d.ts +430 -0
- package/dist/env.js +3152 -0
- package/dist/errors-BMFaY68Q.d.ts +33 -0
- package/dist/errors-BpWbnbb_.js +13 -0
- package/dist/gameplay-Ccruc3Wd.js +1501 -0
- package/dist/gameplay.d.ts +543 -0
- package/dist/gameplay.js +2 -0
- package/dist/heightmap-CroQPEER.js +185 -0
- package/dist/index.d.ts +305 -0
- package/dist/index.js +62 -0
- package/dist/json-BLk7H2Qa.js +30 -0
- package/dist/loader-CGs_G-r0.js +919 -0
- package/dist/loader-Mo0KghCv.d.ts +41 -0
- package/dist/net.d.ts +427 -0
- package/dist/net.js +772 -0
- package/dist/noise-CGUMx44x.js +82 -0
- package/dist/particle-sim-CbN4YUuH.d.ts +63 -0
- package/dist/particle-sim-DYuSUxvK.js +1319 -0
- package/dist/physics-2d-KuMWPTf6.js +288 -0
- package/dist/physics-3d-Dl67vOLT.js +434 -0
- package/dist/react.d.ts +65 -0
- package/dist/react.js +209 -0
- package/dist/register-BuUV1_KB.js +561 -0
- package/dist/register-CNlYAS1_.js +10634 -0
- package/dist/register-DPEV9_9t.js +851 -0
- package/dist/register-Dasmnurl.js +374 -0
- package/dist/registry-BVJ2HbCn.js +132 -0
- package/dist/rng-DP-SR7eg.js +38 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/schema-CcoWb32N.d.ts +104 -0
- package/dist/test.d.ts +158 -0
- package/dist/test.js +275 -0
- package/dist/touch-031PxtCR.js +208 -0
- package/dist/vite.d.ts +26 -0
- package/dist/vite.js +57 -0
- package/editor/assets/GameServer-C56iOUgF.js +1 -0
- package/editor/assets/agent8-Bp7QFI7v.js +1 -0
- package/editor/assets/index-DF3tMeKJ.css +1 -0
- package/editor/assets/index-Dl2pjA8e.js +7365 -0
- package/editor/assets/rapier-CEuLKeCu.js +1 -0
- package/editor/assets/rapier-DE6a0vmv.js +1 -0
- package/editor/index.html +169 -0
- package/package.json +97 -0
- package/schemas/scene.schema.json +4254 -0
- package/skills/README.md +9 -0
- package/skills/incanto-3d-character.md +229 -0
- package/skills/incanto-3d-models.md +151 -0
- package/skills/incanto-assets.md +118 -0
- package/skills/incanto-audio.md +309 -0
- package/skills/incanto-behaviors-and-scripts.md +169 -0
- package/skills/incanto-building-2d-games.md +242 -0
- package/skills/incanto-building-3d-games.md +245 -0
- package/skills/incanto-editor.md +163 -0
- package/skills/incanto-environment.md +743 -0
- package/skills/incanto-gameplay-behaviors.md +707 -0
- package/skills/incanto-multiplayer.md +264 -0
- package/skills/incanto-node-reference.md +797 -0
- package/skills/incanto-physics-and-input.md +164 -0
- package/skills/incanto-scene-json-authoring.md +325 -0
- package/skills/incanto-verifying-your-game.md +191 -0
- package/skills/incanto-web-integration.md +96 -0
- package/templates/agent8-server.js +84 -0
- 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 };
|