incanto 0.36.2 → 0.38.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/bin/incanto-feel.mjs +93 -0
- package/bin/incanto-playtest.mjs +136 -0
- package/dist/2d.d.ts +2 -2
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +44 -3
- package/dist/3d.js +4 -32
- package/dist/{behavior-DibCwrW7.d.ts → behavior-B5PJ32eu.d.ts} +106 -6
- package/dist/{create-game-D-StzrTj.js → create-game-C4t5gVhc.js} +5 -5
- package/dist/{create-game-DnOu2aAa.js → create-game-DqY2lMBc.js} +783 -689
- package/dist/debug.d.ts +1 -1
- package/dist/{duplicate-Cvb1BSca.js → duplicate-CRtihGmC.js} +1 -1
- package/dist/editor.js +21 -0
- package/dist/{environment-presets-fK9oyrrR.js → environment-presets-C0Axpfth.js} +75 -9
- package/dist/{gameplay-C0MUMSak.js → gameplay-CsJDUQh_.js} +2 -2
- package/dist/gameplay.d.ts +1 -1
- package/dist/gameplay.js +1 -1
- package/dist/index.d.ts +4 -50
- package/dist/index.js +5 -75
- package/dist/{loader-BqigZsfF.d.ts → loader-DSMdqasu.d.ts} +1 -1
- package/dist/{loader-DAZlgqeC.js → loader-r49nDwB4.js} +15 -0
- package/dist/net.d.ts +1 -1
- package/dist/net.js +3 -3
- package/dist/{pathfinding-B3QtdhvZ.d.ts → pathfinding-BrE5Uec6.d.ts} +1 -1
- package/dist/{physics-2d-B4v39hca.js → physics-2d-OfKb0PV_.js} +2 -2
- package/dist/{physics-3d-DFJVMYET.js → physics-3d-C8242nXr.js} +3 -3
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-DXwtIVqP.js → register-B4RRQbyq.js} +2 -2
- package/dist/{register-BHAwM4bK.js → register-Dm7nrd-E.js} +322 -34
- package/dist/{register-COGCNitr.js → register-DnB9m7Rl.js} +2 -2
- package/dist/replay-Cxrr-ApH.d.ts +86 -0
- package/dist/{audit-D7F3n3Nt.js → replay-DH0UOuZ-.js} +72 -2
- package/dist/test-CoDPWx-m.js +1404 -0
- package/dist/test.d.ts +113 -4
- package/dist/test.js +3 -3
- package/dist/vite.js +1 -1
- package/editor/assets/{agent8-DMvBVhe4.js → agent8-BtIlblPm.js} +1 -1
- package/editor/assets/{debug-BgAq55oC.js → debug-BvTmHXI7.js} +1 -1
- package/editor/assets/{index-Cy_QsKbh.js → index-BS01WWgM.js} +91 -91
- package/editor/index.html +1 -1
- package/package.json +3 -1
- package/schemas/scene.schema.json +318 -0
- package/skills/incanto-3d-character.md +35 -0
- package/skills/incanto-building-2d-games.md +6 -0
- package/skills/incanto-building-3d-games.md +6 -0
- package/skills/incanto-game-feel.md +76 -0
- package/skills/incanto-node-reference.md +51 -0
- package/skills/incanto-performance.md +212 -0
- package/skills/incanto-playtesting.md +143 -0
- package/skills/incanto-verifying-your-game.md +41 -0
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/village-quest-3d/package.json +1 -1
- package/templates-app/village-quest-3d/src/village.scene.json +3 -1
- package/templates-app/village-quest-3d/verify.ts +55 -5
- package/dist/errors-DGRtWlSx.d.ts +0 -39
- package/dist/test-X_xE6Yay.js +0 -642
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
|
-
import { n as loadScene, v as diagnose, w as registerBehavior } from "./loader-
|
|
3
|
-
import { h as Engine, m as AudioPlayer } from "./register-
|
|
2
|
+
import { n as loadScene, v as diagnose, w as registerBehavior } from "./loader-r49nDwB4.js";
|
|
3
|
+
import { h as Engine, m as AudioPlayer, v as qualityRendering } from "./register-Dm7nrd-E.js";
|
|
4
4
|
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
5
5
|
import { i as resolveRendering, n as attachTouchControls } from "./touch-BoNg_MnF.js";
|
|
6
6
|
import { a as poseFromRenderer, i as openBundledEditor, n as crossFade, r as devServerLibrary, t as teardown } from "./teardown-CCtAMDLB.js";
|
|
7
|
-
import { B as createCausticsQuad, F as PhysicsBody3D, R as Node3D, n as registerGameplayBehaviors } from "./gameplay-
|
|
7
|
+
import { B as createCausticsQuad, F as PhysicsBody3D, R as Node3D, n as registerGameplayBehaviors } from "./gameplay-CsJDUQh_.js";
|
|
8
8
|
import { t as debugSources } from "./debug-draw-BM3DsvtT.js";
|
|
9
|
-
import { E as Camera3D, U as TextureCache3D, g as ModelInstance3D, n as registerNodes3D, t as resolveEnvironmentHdri, v as DirectionalLight3D } from "./environment-presets-
|
|
10
|
-
import { n as enablePhysics3D } from "./physics-3d-
|
|
9
|
+
import { E as Camera3D, U as TextureCache3D, g as ModelInstance3D, n as registerNodes3D, t as resolveEnvironmentHdri, v as DirectionalLight3D } from "./environment-presets-C0Axpfth.js";
|
|
10
|
+
import { n as enablePhysics3D } from "./physics-3d-C8242nXr.js";
|
|
11
11
|
import { ACESFilmicToneMapping, AmbientLight, Box3, BufferAttribute, BufferGeometry, Color, DepthTexture, EquirectangularReflectionMapping, FloatType, Fog, HalfFloatType, LineBasicMaterial, LineSegments, Matrix4, Mesh, PCFShadowMap, PMREMGenerator, PerspectiveCamera, PlaneGeometry, Quaternion, Raycaster, Scene, ShaderMaterial, Vector2, Vector3, WebGLRenderTarget, WebGLRenderer } from "three";
|
|
12
12
|
import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
|
|
13
13
|
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
@@ -172,762 +172,814 @@ var AssetStore3D = class {
|
|
|
172
172
|
}
|
|
173
173
|
};
|
|
174
174
|
//#endregion
|
|
175
|
-
//#region src/3d/
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
175
|
+
//#region src/3d/environment.ts
|
|
176
|
+
const SKY_KEYS = [
|
|
177
|
+
"type",
|
|
178
|
+
"sunPosition",
|
|
179
|
+
"elevationDeg",
|
|
180
|
+
"azimuthDeg",
|
|
181
|
+
"turbidity",
|
|
182
|
+
"rayleigh"
|
|
183
|
+
];
|
|
184
|
+
const FOG_KEYS = [
|
|
185
|
+
"color",
|
|
186
|
+
"near",
|
|
187
|
+
"far"
|
|
188
|
+
];
|
|
189
|
+
const CLOUD_KEYS = [
|
|
190
|
+
"coverage",
|
|
191
|
+
"density",
|
|
192
|
+
"base",
|
|
193
|
+
"top",
|
|
194
|
+
"color",
|
|
195
|
+
"shadeColor",
|
|
196
|
+
"speed",
|
|
197
|
+
"scale"
|
|
198
|
+
];
|
|
199
|
+
const BLOOM_KEYS = ["threshold", "strength"];
|
|
200
|
+
const POST_KEYS = [
|
|
201
|
+
"vignette",
|
|
202
|
+
"saturation",
|
|
203
|
+
"contrast"
|
|
204
|
+
];
|
|
205
|
+
const SHADOW_KEYS = [
|
|
206
|
+
"mapSize",
|
|
207
|
+
"radius",
|
|
208
|
+
"static"
|
|
209
|
+
];
|
|
210
|
+
const SHADOW_MAP_SIZES = [1024, 2048];
|
|
211
|
+
const DEFAULT_TURBIDITY = 2;
|
|
212
|
+
const DEFAULT_RAYLEIGH = 1;
|
|
213
|
+
const DEFAULT_FOG_NEAR = 50;
|
|
214
|
+
const DEFAULT_FOG_FAR = 800;
|
|
215
|
+
/** Fallback haze when fog is declared without a sky to derive a horizon from. */
|
|
216
|
+
const DEFAULT_FOG_COLOR = "#cfd8e0";
|
|
217
|
+
const DEG2RAD = Math.PI / 180;
|
|
218
|
+
/** Parse + hard-validate the 3D slice of a scene's `environment` header. */
|
|
219
|
+
function parseEnvironment3D(env) {
|
|
220
|
+
return {
|
|
221
|
+
exposure: parseExposure(env?.exposure),
|
|
222
|
+
sky: parseSky(env?.sky),
|
|
223
|
+
fog: parseFog(env?.fog, env?.sky !== void 0),
|
|
224
|
+
clouds: parseClouds(env?.clouds),
|
|
225
|
+
bloom: parseBloom(env?.bloom),
|
|
226
|
+
post: parsePost(env?.post),
|
|
227
|
+
shadows: parseShadows(env?.shadows)
|
|
228
|
+
};
|
|
229
|
+
}
|
|
220
230
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* tone mapping nor the sRGB transform when rendering into a target)
|
|
225
|
-
* bright-pass at HALF resolution (keep only luminance above `threshold`;
|
|
226
|
-
* with HDR buffers, emissives and sun glints sit above 1.0)
|
|
227
|
-
* separable 9-tap Gaussian blur (H then V) at half res
|
|
228
|
-
* composite: scene + glow × strength, THEN the standard postprocessing tail
|
|
229
|
-
* (ACES tone mapping + linear→sRGB) — skipping that tail is exactly the
|
|
230
|
-
* dark/oversaturated bug the volumetric-clouds pipeline shipped with.
|
|
231
|
+
* elevation/azimuth (degrees) → unit direction. Matches three's Sky example
|
|
232
|
+
* (`Vector3.setFromSphericalCoords`): azimuth 0 = +Z, 90 = +X; elevation 90 =
|
|
233
|
+
* straight up.
|
|
231
234
|
*/
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
235
|
+
function sunDirectionFromElevationAzimuth(elevationDeg, azimuthDeg) {
|
|
236
|
+
const phi = (90 - elevationDeg) * DEG2RAD;
|
|
237
|
+
const theta = azimuthDeg * DEG2RAD;
|
|
238
|
+
return [
|
|
239
|
+
Math.sin(phi) * Math.sin(theta),
|
|
240
|
+
Math.cos(phi),
|
|
241
|
+
Math.sin(phi) * Math.cos(theta)
|
|
242
|
+
];
|
|
237
243
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
// applied later, in the composite), so emissives/sun glints sit ABOVE 1.0 —
|
|
248
|
-
// keep only the energy above the threshold, in proportion
|
|
249
|
-
float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
|
250
|
-
float k = max(l - uThreshold, 0.0);
|
|
251
|
-
gl_FragColor = vec4(c * (k / max(l, 1e-4)), 1.0);
|
|
244
|
+
/** The sky's sun as a UNIT vector — what Water3D/Foliage3D uniforms consume. */
|
|
245
|
+
function sunDirectionFromSky(sky) {
|
|
246
|
+
const [x, y, z] = sky.sunPosition;
|
|
247
|
+
const len = Math.hypot(x, y, z) || 1;
|
|
248
|
+
return [
|
|
249
|
+
x / len,
|
|
250
|
+
y / len,
|
|
251
|
+
z / len
|
|
252
|
+
];
|
|
252
253
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
254
|
+
/**
|
|
255
|
+
* A horizon-ish haze color derived from the sky config — the default fog
|
|
256
|
+
* color. Cheap model, judged by eye: clear skies haze blue-grey, turbid skies
|
|
257
|
+
* whiten, and a low sun warms the band toward amber.
|
|
258
|
+
*/
|
|
259
|
+
function horizonColorFromSky(sky) {
|
|
260
|
+
const clear = [
|
|
261
|
+
191,
|
|
262
|
+
213,
|
|
263
|
+
232
|
|
264
|
+
];
|
|
265
|
+
const hazy = [
|
|
266
|
+
233,
|
|
267
|
+
228,
|
|
268
|
+
217
|
|
269
|
+
];
|
|
270
|
+
const warm = [
|
|
271
|
+
242,
|
|
272
|
+
201,
|
|
273
|
+
150
|
|
274
|
+
];
|
|
275
|
+
const t = clamp01((sky.turbidity - DEFAULT_TURBIDITY) / 8);
|
|
276
|
+
const dir = sunDirectionFromSky(sky);
|
|
277
|
+
const w = clamp01((18 - Math.asin(clamp(dir[1], -1, 1)) / DEG2RAD) / 18) * .8;
|
|
278
|
+
const mix = (i) => Math.round(lerp(lerp(clear[i], hazy[i], t), warm[i], w));
|
|
279
|
+
return `#${[
|
|
280
|
+
mix(0),
|
|
281
|
+
mix(1),
|
|
282
|
+
mix(2)
|
|
283
|
+
].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
|
|
267
284
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
uniform sampler2D tScene;
|
|
273
|
-
uniform sampler2D tBloom;
|
|
274
|
-
uniform float uStrength;
|
|
275
|
-
uniform float uVignette;
|
|
276
|
-
uniform float uSaturation;
|
|
277
|
-
uniform float uContrast;
|
|
278
|
-
void main() {
|
|
279
|
-
vec3 base = texture2D(tScene, vUv).rgb;
|
|
280
|
-
vec3 glow = texture2D(tBloom, vUv).rgb;
|
|
281
|
-
gl_FragColor = vec4(base + glow * uStrength, 1.0);
|
|
282
|
-
// three skips BOTH tone mapping and the output colorspace transform when the
|
|
283
|
-
// scene renders into an offscreen target — the buffer holds raw linear HDR.
|
|
284
|
-
// This is the final to-screen pass, so apply the standard postprocessing
|
|
285
|
-
// tail here (ACES then linear→sRGB); without it the frame comes out dark
|
|
286
|
-
// and oversaturated (the volumetric-clouds pipeline bug, root-caused).
|
|
287
|
-
#include <tonemapping_fragment>
|
|
288
|
-
// color grade AFTER tone mapping (values are display-referred now) and
|
|
289
|
-
// BEFORE the colorspace transform: saturation → contrast → vignette
|
|
290
|
-
vec3 graded = gl_FragColor.rgb;
|
|
291
|
-
float luma = dot(graded, vec3(0.2126, 0.7152, 0.0722));
|
|
292
|
-
graded = mix(vec3(luma), graded, uSaturation);
|
|
293
|
-
graded = (graded - 0.5) * uContrast + 0.5;
|
|
294
|
-
float dist = distance(vUv, vec2(0.5));
|
|
295
|
-
graded *= 1.0 - uVignette * smoothstep(0.3, 0.78, dist);
|
|
296
|
-
gl_FragColor.rgb = max(graded, 0.0);
|
|
297
|
-
#include <colorspace_fragment>
|
|
285
|
+
function parseExposure(value) {
|
|
286
|
+
if (value === void 0) return 1;
|
|
287
|
+
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" });
|
|
288
|
+
return value;
|
|
298
289
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
uniforms
|
|
290
|
+
function parseSky(value) {
|
|
291
|
+
if (value === void 0) return null;
|
|
292
|
+
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" });
|
|
293
|
+
const sky = value;
|
|
294
|
+
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(", ")}].`, {
|
|
295
|
+
prop: "sky",
|
|
296
|
+
validOptions: SKY_KEYS
|
|
307
297
|
});
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
298
|
+
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)}.`, {
|
|
299
|
+
prop: "sky",
|
|
300
|
+
validOptions: ["atmosphere"]
|
|
301
|
+
});
|
|
302
|
+
const hasAngles = sky.elevationDeg !== void 0 || sky.azimuthDeg !== void 0;
|
|
303
|
+
if (sky.sunPosition !== void 0 && hasAngles) throw new IncantoError("BAD_FORMAT", `environment.sky takes sunPosition OR elevationDeg/azimuthDeg, not both.`, {
|
|
304
|
+
prop: "sky",
|
|
305
|
+
validOptions: ["sunPosition", "elevationDeg+azimuthDeg"]
|
|
306
|
+
});
|
|
307
|
+
let sunPosition;
|
|
308
|
+
if (sky.sunPosition !== void 0) {
|
|
309
|
+
const sp = sky.sunPosition;
|
|
310
|
+
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" });
|
|
311
|
+
sunPosition = [
|
|
312
|
+
sp[0],
|
|
313
|
+
sp[1],
|
|
314
|
+
sp[2]
|
|
315
|
+
];
|
|
316
|
+
} else {
|
|
317
|
+
const elevation = numberOr(sky.elevationDeg, 32, "sky.elevationDeg");
|
|
318
|
+
const azimuth = numberOr(sky.azimuthDeg, 135, "sky.azimuthDeg");
|
|
319
|
+
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" });
|
|
320
|
+
sunPosition = sunDirectionFromElevationAzimuth(elevation, azimuth);
|
|
321
|
+
}
|
|
322
|
+
const turbidity = numberOr(sky.turbidity, DEFAULT_TURBIDITY, "sky.turbidity");
|
|
323
|
+
if (turbidity <= 0) throw new IncantoError("BAD_FORMAT", `environment.sky.turbidity must be > 0 (atmospheric haze; 2 ≈ clear day), got ${turbidity}.`, { prop: "sky" });
|
|
324
|
+
const rayleigh = numberOr(sky.rayleigh, DEFAULT_RAYLEIGH, "sky.rayleigh");
|
|
325
|
+
if (rayleigh < 0) throw new IncantoError("BAD_FORMAT", `environment.sky.rayleigh must be >= 0 (Rayleigh scattering; 1 ≈ earth-like), got ${rayleigh}.`, { prop: "sky" });
|
|
326
|
+
return {
|
|
327
|
+
sunPosition,
|
|
328
|
+
turbidity,
|
|
329
|
+
rayleigh
|
|
330
|
+
};
|
|
311
331
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
332
|
+
function parseFog(value, hasSky) {
|
|
333
|
+
if (value === void 0) return null;
|
|
334
|
+
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" });
|
|
335
|
+
const fog = value;
|
|
336
|
+
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(", ")}].`, {
|
|
337
|
+
prop: "fog",
|
|
338
|
+
validOptions: FOG_KEYS
|
|
339
|
+
});
|
|
340
|
+
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" });
|
|
341
|
+
const near = numberOr(fog.near, DEFAULT_FOG_NEAR, "fog.near");
|
|
342
|
+
const far = numberOr(fog.far, DEFAULT_FOG_FAR, "fog.far");
|
|
343
|
+
if (near < 0) throw new IncantoError("BAD_FORMAT", `environment.fog.near must be >= 0 meters, got ${near}.`, { prop: "fog" });
|
|
344
|
+
if (far <= near) throw new IncantoError("BAD_FORMAT", `environment.fog.far must be > near (got near ${near}, far ${far}).`, { prop: "fog" });
|
|
345
|
+
return {
|
|
346
|
+
color: fog.color ?? (hasSky ? "" : DEFAULT_FOG_COLOR),
|
|
347
|
+
near,
|
|
348
|
+
far
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function parseClouds(value) {
|
|
352
|
+
if (value === void 0) return null;
|
|
353
|
+
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" });
|
|
354
|
+
const c = value;
|
|
355
|
+
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(", ")}].`, {
|
|
356
|
+
prop: "clouds",
|
|
357
|
+
validOptions: CLOUD_KEYS
|
|
358
|
+
});
|
|
359
|
+
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" });
|
|
360
|
+
const coverage = numberOr(c.coverage, .5, "clouds.coverage");
|
|
361
|
+
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" });
|
|
362
|
+
const density = numberOr(c.density, 1, "clouds.density");
|
|
363
|
+
if (density < 0) throw new IncantoError("BAD_FORMAT", `environment.clouds.density must be >= 0 (optical thickness), got ${density}.`, { prop: "clouds" });
|
|
364
|
+
const base = numberOr(c.base, 120, "clouds.base");
|
|
365
|
+
const top = numberOr(c.top, 320, "clouds.top");
|
|
366
|
+
if (top <= base) throw new IncantoError("BAD_FORMAT", `environment.clouds.top must be > base (got base ${base}, top ${top}).`, { prop: "clouds" });
|
|
367
|
+
const speed = numberOr(c.speed, 1, "clouds.speed");
|
|
368
|
+
const scale = numberOr(c.scale, 240, "clouds.scale");
|
|
369
|
+
if (scale <= 0) throw new IncantoError("BAD_FORMAT", `environment.clouds.scale must be > 0 (feature size in world units), got ${scale}.`, { prop: "clouds" });
|
|
370
|
+
return {
|
|
371
|
+
coverage,
|
|
372
|
+
density,
|
|
373
|
+
base,
|
|
374
|
+
top,
|
|
375
|
+
color: c.color ?? "#ffffff",
|
|
376
|
+
shadeColor: c.shadeColor ?? "#9fb0c8",
|
|
377
|
+
speed,
|
|
378
|
+
scale
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function parseBloom(value) {
|
|
382
|
+
if (value === void 0) return null;
|
|
383
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.bloom must be an object ({ threshold?, strength? }), got ${JSON.stringify(value)}.`, { prop: "bloom" });
|
|
384
|
+
const b = value;
|
|
385
|
+
for (const key of Object.keys(b)) if (!BLOOM_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.bloom has unknown key '${key}'. Valid keys: [${BLOOM_KEYS.join(", ")}].`, {
|
|
386
|
+
prop: "bloom",
|
|
387
|
+
validOptions: BLOOM_KEYS
|
|
388
|
+
});
|
|
389
|
+
const threshold = numberOr(b.threshold, 1, "bloom.threshold");
|
|
390
|
+
if (threshold < 0 || threshold > 8) throw new IncantoError("BAD_FORMAT", `environment.bloom.threshold must be in [0, 8] (linear-HDR luminance; 1 = white), got ${threshold}.`, { prop: "bloom" });
|
|
391
|
+
const strength = numberOr(b.strength, .8, "bloom.strength");
|
|
392
|
+
if (strength < 0) throw new IncantoError("BAD_FORMAT", `environment.bloom.strength must be >= 0, got ${strength}.`, { prop: "bloom" });
|
|
393
|
+
return {
|
|
394
|
+
threshold,
|
|
395
|
+
strength
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function parsePost(value) {
|
|
399
|
+
if (value === void 0) return null;
|
|
400
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.post must be an object ({ vignette?, saturation?, contrast? }), got ${JSON.stringify(value)}.`, { prop: "post" });
|
|
401
|
+
const p = value;
|
|
402
|
+
for (const key of Object.keys(p)) if (!POST_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.post has unknown key '${key}'. Valid keys: [${POST_KEYS.join(", ")}].`, {
|
|
403
|
+
prop: "post",
|
|
404
|
+
validOptions: POST_KEYS
|
|
317
405
|
});
|
|
406
|
+
const vignette = numberOr(p.vignette, 0, "post.vignette");
|
|
407
|
+
if (vignette < 0 || vignette > 1) throw new IncantoError("BAD_FORMAT", `environment.post.vignette must be in [0, 1], got ${vignette}.`, { prop: "post" });
|
|
408
|
+
const saturation = numberOr(p.saturation, 1, "post.saturation");
|
|
409
|
+
if (saturation < 0 || saturation > 4) throw new IncantoError("BAD_FORMAT", `environment.post.saturation must be in [0, 4] (1 = neutral), got ${saturation}.`, { prop: "post" });
|
|
410
|
+
const contrast = numberOr(p.contrast, 1, "post.contrast");
|
|
411
|
+
if (contrast < .2 || contrast > 3) throw new IncantoError("BAD_FORMAT", `environment.post.contrast must be in [0.2, 3] (1 = neutral), got ${contrast}.`, { prop: "post" });
|
|
318
412
|
return {
|
|
319
|
-
|
|
320
|
-
|
|
413
|
+
vignette,
|
|
414
|
+
saturation,
|
|
415
|
+
contrast
|
|
321
416
|
};
|
|
322
417
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
mesh,
|
|
331
|
-
uniforms: mesh.material.uniforms
|
|
418
|
+
function parseShadows(value) {
|
|
419
|
+
if (value === void 0) return null;
|
|
420
|
+
if (value === false) return false;
|
|
421
|
+
if (value === true) return {
|
|
422
|
+
mapSize: 2048,
|
|
423
|
+
radius: 1,
|
|
424
|
+
static: false
|
|
332
425
|
};
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
426
|
+
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" });
|
|
427
|
+
const shadows = value;
|
|
428
|
+
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(", ")}].`, {
|
|
429
|
+
prop: "shadows",
|
|
430
|
+
validOptions: SHADOW_KEYS
|
|
431
|
+
});
|
|
432
|
+
const mapSize = shadows.mapSize === void 0 ? 2048 : shadows.mapSize;
|
|
433
|
+
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)}.`, {
|
|
434
|
+
prop: "shadows",
|
|
435
|
+
validOptions: SHADOW_MAP_SIZES.map(String)
|
|
343
436
|
});
|
|
437
|
+
const radius = numberOr(shadows.radius, 1, "shadows.radius");
|
|
438
|
+
if (radius < 0) throw new IncantoError("BAD_FORMAT", `environment.shadows.radius must be >= 0, got ${radius}.`, { prop: "shadows" });
|
|
439
|
+
const staticShadows = shadows.static === void 0 ? false : shadows.static;
|
|
440
|
+
if (typeof staticShadows !== "boolean") throw new IncantoError("BAD_FORMAT", `environment.shadows.static must be a boolean, got ${JSON.stringify(shadows.static)}.`, { prop: "shadows" });
|
|
344
441
|
return {
|
|
345
|
-
|
|
346
|
-
|
|
442
|
+
mapSize,
|
|
443
|
+
radius,
|
|
444
|
+
static: staticShadows
|
|
347
445
|
};
|
|
348
446
|
}
|
|
447
|
+
function numberOr(value, fallback, at) {
|
|
448
|
+
if (value === void 0) return fallback;
|
|
449
|
+
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 });
|
|
450
|
+
return value;
|
|
451
|
+
}
|
|
452
|
+
function clamp(v, min, max) {
|
|
453
|
+
return Math.min(Math.max(v, min), max);
|
|
454
|
+
}
|
|
455
|
+
function clamp01(v) {
|
|
456
|
+
return clamp(v, 0, 1);
|
|
457
|
+
}
|
|
458
|
+
function lerp(a, b, t) {
|
|
459
|
+
return a + (b - a) * t;
|
|
460
|
+
}
|
|
349
461
|
//#endregion
|
|
350
|
-
//#region src/3d/
|
|
462
|
+
//#region src/3d/environment-runtime.ts
|
|
351
463
|
/**
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
* is declared:
|
|
464
|
+
* Live environment editing — the renderer re-applies `scene.environment`
|
|
465
|
+
* every frame (cheap when unchanged), so changing the LOOK at runtime is
|
|
466
|
+
* just writing that object. This helper does it safely:
|
|
356
467
|
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
* depth/inverse-view-proj, marches it through a world-space altitude slab
|
|
360
|
-
* [uBase, uTop], accumulates fractal-noise cloud density with cheap
|
|
361
|
-
* single-scatter sun lighting, and composites the result OVER the scene —
|
|
362
|
-
* clipped to the scene depth so clouds behind solid geometry are hidden.
|
|
468
|
+
* setEnvironment3D(engine, { sky: { elevationDeg: 4 }, exposure: 0.8 });
|
|
469
|
+
* setEnvironment3D(engine, { fog: null }); // null DELETES a key
|
|
363
470
|
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
471
|
+
* - one-level deep merge (patching `sky.elevationDeg` keeps the other sky keys)
|
|
472
|
+
* - hard-validates the merged result NOW (in your call stack), so a bad patch
|
|
473
|
+
* throws where you wrote it — never inside the render loop
|
|
367
474
|
*/
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
uniform vec3 uCameraPos;
|
|
381
|
-
uniform float uTime;
|
|
382
|
-
uniform vec3 uSunDir; // unit, toward the sun
|
|
383
|
-
uniform vec3 uSunColor;
|
|
384
|
-
uniform vec3 uCloudColor; // sunlit
|
|
385
|
-
uniform vec3 uShadeColor; // shaded underside
|
|
386
|
-
uniform vec3 uHorizonColor; // distant haze the clouds fade into
|
|
387
|
-
uniform float uBase;
|
|
388
|
-
uniform float uTop;
|
|
389
|
-
uniform float uCoverage; // 0..1
|
|
390
|
-
uniform float uDensity; // optical thickness multiplier
|
|
391
|
-
uniform float uScale; // feature size (world units)
|
|
392
|
-
uniform vec2 uWind; // drift dir * speed
|
|
393
|
-
uniform float uFarFade; // distance over which clouds melt into haze
|
|
394
|
-
|
|
395
|
-
// ---- value noise + FBM (hash-based, no texture) ----------------------------------
|
|
396
|
-
float hash(vec3 p) {
|
|
397
|
-
p = fract(p * 0.3183099 + 0.1);
|
|
398
|
-
p *= 17.0;
|
|
399
|
-
return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
|
|
400
|
-
}
|
|
401
|
-
float vnoise(vec3 x) {
|
|
402
|
-
vec3 i = floor(x);
|
|
403
|
-
vec3 f = fract(x);
|
|
404
|
-
f = f * f * (3.0 - 2.0 * f);
|
|
405
|
-
return mix(
|
|
406
|
-
mix(mix(hash(i + vec3(0,0,0)), hash(i + vec3(1,0,0)), f.x),
|
|
407
|
-
mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
|
|
408
|
-
mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
|
|
409
|
-
mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y),
|
|
410
|
-
f.z);
|
|
411
|
-
}
|
|
412
|
-
float fbm(vec3 p) {
|
|
413
|
-
float s = 0.0;
|
|
414
|
-
float a = 0.5;
|
|
415
|
-
for (int i = 0; i < 3; i++) {
|
|
416
|
-
s += a * vnoise(p);
|
|
417
|
-
p = p * 2.04 + vec3(11.7, 3.1, 5.3);
|
|
418
|
-
a *= 0.5;
|
|
419
|
-
}
|
|
420
|
-
return s;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// vertical envelope: wispy base + top, dense middle → cumulus, not a flat slab
|
|
424
|
-
float cloudEnv(vec3 wp) {
|
|
425
|
-
float h = clamp((wp.y - uBase) / (uTop - uBase), 0.0, 1.0);
|
|
426
|
-
return smoothstep(0.0, 0.18, h) * smoothstep(1.0, 0.55, h);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// COARSE density (base FBM only) — used for the sun light-march, which doesn't
|
|
430
|
-
// need the fine erosion detail, so each lit step stays cheap (one fbm).
|
|
431
|
-
float cloudDensityCoarse(vec3 wp) {
|
|
432
|
-
float env = cloudEnv(wp);
|
|
433
|
-
if (env <= 0.0) return 0.0;
|
|
434
|
-
vec3 q = wp / uScale;
|
|
435
|
-
q.xz += uWind * uTime * 0.03;
|
|
436
|
-
return max(0.0, fbm(q) - (1.0 - uCoverage)) * env * 2.0;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// FULL density: coarse shape eroded by two higher-frequency layers → crisp
|
|
440
|
-
// billowy cauliflower edges instead of soft amorphous blobs.
|
|
441
|
-
float cloudDensity(vec3 wp) {
|
|
442
|
-
float env = cloudEnv(wp);
|
|
443
|
-
if (env <= 0.0) return 0.0;
|
|
444
|
-
vec3 q = wp / uScale;
|
|
445
|
-
q.xz += uWind * uTime * 0.03;
|
|
446
|
-
float d = fbm(q) - (1.0 - uCoverage);
|
|
447
|
-
if (d <= 0.0) return 0.0; // empty space — skip the erosion noise (cheap)
|
|
448
|
-
d -= 0.30 * vnoise(q * 3.2 + 4.0);
|
|
449
|
-
d -= 0.14 * vnoise(q * 8.5 + 9.0);
|
|
450
|
-
return max(0.0, d) * env * 2.0;
|
|
475
|
+
function setEnvironment3D(engine, patch) {
|
|
476
|
+
const scene = engine.scene;
|
|
477
|
+
if (!scene) throw new IncantoError("BAD_FORMAT", "setEnvironment3D: the engine has no scene.");
|
|
478
|
+
const merged = { ...scene.environment ?? {} };
|
|
479
|
+
for (const [key, value] of Object.entries(patch)) if (value === null) delete merged[key];
|
|
480
|
+
else if (typeof value === "object" && !Array.isArray(value) && typeof merged[key] === "object" && merged[key] !== null && !Array.isArray(merged[key])) merged[key] = {
|
|
481
|
+
...merged[key],
|
|
482
|
+
...value
|
|
483
|
+
};
|
|
484
|
+
else merged[key] = value;
|
|
485
|
+
parseEnvironment3D(merged);
|
|
486
|
+
scene.environment = merged;
|
|
451
487
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region src/3d/adaptive-resolution.ts
|
|
490
|
+
const DEFAULTS = {
|
|
491
|
+
slowMs: 20,
|
|
492
|
+
fastMs: 13,
|
|
493
|
+
minScale: .6,
|
|
494
|
+
step: .15,
|
|
495
|
+
window: 45
|
|
496
|
+
};
|
|
497
|
+
var AdaptiveResolution = class {
|
|
498
|
+
opts;
|
|
499
|
+
samples = [];
|
|
500
|
+
scale = 1;
|
|
501
|
+
/** Thresholds the caller pinned by hand — `setTarget` must not move these. */
|
|
502
|
+
pinned;
|
|
503
|
+
constructor(options = {}) {
|
|
504
|
+
this.opts = {
|
|
505
|
+
...DEFAULTS,
|
|
506
|
+
...options
|
|
507
|
+
};
|
|
508
|
+
this.pinned = {
|
|
509
|
+
slowMs: options.slowMs !== void 0,
|
|
510
|
+
fastMs: options.fastMs !== void 0
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Judge frames against `fps` instead of 60.
|
|
515
|
+
*
|
|
516
|
+
* The defaults above are a 60 fps budget with a margin: 1000/60 × 1.2 = 20.0
|
|
517
|
+
* and × 0.8 = 13.3, which is exactly `slowMs` and `fastMs`. That was fine
|
|
518
|
+
* while 60 was the only target anyone had — and then the frame cap arrived.
|
|
519
|
+
*
|
|
520
|
+
* Cap a game at 30 and every frame takes 33 ms BY DESIGN. Against a hardcoded
|
|
521
|
+
* 20 ms budget the governor reads that as a game missing its frames, sheds
|
|
522
|
+
* pixels every window until it hits `minScale`, and never hands them back:
|
|
523
|
+
* the player asks for 30 fps to save the battery and silently gets 30 fps at
|
|
524
|
+
* 60% resolution. The budget has to come from the target, so that "slow"
|
|
525
|
+
* means "slower than what we asked for".
|
|
526
|
+
*/
|
|
527
|
+
setTarget(fps) {
|
|
528
|
+
const frameMs = 1e3 / (fps > 0 ? fps : 60);
|
|
529
|
+
if (!this.pinned.slowMs) this.opts.slowMs = frameMs * 1.2;
|
|
530
|
+
if (!this.pinned.fastMs) this.opts.fastMs = frameMs * .8;
|
|
531
|
+
}
|
|
532
|
+
/** The fraction of the requested pixel ratio to render at right now. */
|
|
533
|
+
current() {
|
|
534
|
+
return this.scale;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Feed one frame's duration. Returns the new scale when it CHANGED (the
|
|
538
|
+
* caller then resizes), else null — so a steady frame rate costs nothing.
|
|
539
|
+
*/
|
|
540
|
+
push(frameMs) {
|
|
541
|
+
if (!(frameMs > 0) || !Number.isFinite(frameMs)) return null;
|
|
542
|
+
this.samples.push(frameMs);
|
|
543
|
+
if (this.samples.length < this.opts.window) return null;
|
|
544
|
+
const sorted = [...this.samples].sort((a, b) => a - b);
|
|
545
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
546
|
+
this.samples = [];
|
|
547
|
+
const before = this.scale;
|
|
548
|
+
if (median > this.opts.slowMs) this.scale = Math.max(this.opts.minScale, this.scale - this.opts.step);
|
|
549
|
+
else if (median < this.opts.fastMs && this.scale < 1) this.scale = Math.min(1, this.scale + this.opts.step);
|
|
550
|
+
return this.scale === before ? null : this.scale;
|
|
551
|
+
}
|
|
552
|
+
/** Forget the window (a paused or backgrounded game is not "slow"). */
|
|
553
|
+
reset() {
|
|
554
|
+
this.samples = [];
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
//#endregion
|
|
558
|
+
//#region src/3d/bloom/bloom-composite.ts
|
|
559
|
+
/**
|
|
560
|
+
* HDR bloom post pass:
|
|
561
|
+
*
|
|
562
|
+
* scene → offscreen HalfFloat target (raw linear HDR — three applies neither
|
|
563
|
+
* tone mapping nor the sRGB transform when rendering into a target)
|
|
564
|
+
* bright-pass at HALF resolution (keep only luminance above `threshold`;
|
|
565
|
+
* with HDR buffers, emissives and sun glints sit above 1.0)
|
|
566
|
+
* separable 9-tap Gaussian blur (H then V) at half res
|
|
567
|
+
* composite: scene + glow × strength, THEN the standard postprocessing tail
|
|
568
|
+
* (ACES tone mapping + linear→sRGB) — skipping that tail is exactly the
|
|
569
|
+
* dark/oversaturated bug the volumetric-clouds pipeline shipped with.
|
|
570
|
+
*/
|
|
571
|
+
const VERT = `
|
|
572
|
+
varying vec2 vUv;
|
|
573
|
+
void main() {
|
|
574
|
+
vUv = uv;
|
|
575
|
+
gl_Position = vec4(position.xy, 0.0, 1.0);
|
|
536
576
|
}
|
|
537
577
|
`;
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
578
|
+
const BRIGHT_FRAG = `
|
|
579
|
+
precision highp float;
|
|
580
|
+
varying vec2 vUv;
|
|
581
|
+
uniform sampler2D tScene;
|
|
582
|
+
uniform float uThreshold;
|
|
583
|
+
void main() {
|
|
584
|
+
vec3 c = texture2D(tScene, vUv).rgb;
|
|
585
|
+
// HDR luminance knee: the scene buffer is raw linear HDR (tone mapping is
|
|
586
|
+
// applied later, in the composite), so emissives/sun glints sit ABOVE 1.0 —
|
|
587
|
+
// keep only the energy above the threshold, in proportion
|
|
588
|
+
float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
|
589
|
+
float k = max(l - uThreshold, 0.0);
|
|
590
|
+
gl_FragColor = vec4(c * (k / max(l, 1e-4)), 1.0);
|
|
591
|
+
}
|
|
592
|
+
`;
|
|
593
|
+
const BLUR_FRAG = `
|
|
542
594
|
precision highp float;
|
|
543
595
|
varying vec2 vUv;
|
|
544
596
|
uniform sampler2D tTex;
|
|
545
|
-
uniform vec2 uDir;
|
|
597
|
+
uniform vec2 uDir;
|
|
546
598
|
void main() {
|
|
547
|
-
// 9-tap Gaussian
|
|
548
|
-
vec4
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
c += texture2D(tTex, vUv - uDir * 3.0) * 0.04;
|
|
555
|
-
c += texture2D(tTex, vUv + uDir * 4.0) * 0.015;
|
|
556
|
-
c += texture2D(tTex, vUv - uDir * 4.0) * 0.015;
|
|
557
|
-
gl_FragColor = c;
|
|
599
|
+
// 9-tap Gaussian, sigma ~2 texels (same recipe as the cloud blur)
|
|
600
|
+
vec4 sum = texture2D(tTex, vUv) * 0.227027;
|
|
601
|
+
sum += texture2D(tTex, vUv + uDir * 1.3846) * 0.3162162;
|
|
602
|
+
sum += texture2D(tTex, vUv - uDir * 1.3846) * 0.3162162;
|
|
603
|
+
sum += texture2D(tTex, vUv + uDir * 3.2308) * 0.0702703;
|
|
604
|
+
sum += texture2D(tTex, vUv - uDir * 3.2308) * 0.0702703;
|
|
605
|
+
gl_FragColor = sum;
|
|
558
606
|
}
|
|
559
607
|
`;
|
|
560
|
-
|
|
561
|
-
const CLOUDS_COMPOSITE_FRAG = `
|
|
608
|
+
const COMPOSITE_FRAG = `
|
|
562
609
|
precision highp float;
|
|
563
610
|
varying vec2 vUv;
|
|
564
611
|
uniform sampler2D tScene;
|
|
565
|
-
uniform sampler2D
|
|
612
|
+
uniform sampler2D tBloom;
|
|
613
|
+
uniform float uStrength;
|
|
614
|
+
uniform float uVignette;
|
|
615
|
+
uniform float uSaturation;
|
|
616
|
+
uniform float uContrast;
|
|
566
617
|
void main() {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
gl_FragColor = vec4(
|
|
618
|
+
vec3 base = texture2D(tScene, vUv).rgb;
|
|
619
|
+
vec3 glow = texture2D(tBloom, vUv).rgb;
|
|
620
|
+
gl_FragColor = vec4(base + glow * uStrength, 1.0);
|
|
621
|
+
// three skips BOTH tone mapping and the output colorspace transform when the
|
|
622
|
+
// scene renders into an offscreen target — the buffer holds raw linear HDR.
|
|
623
|
+
// This is the final to-screen pass, so apply the standard postprocessing
|
|
624
|
+
// tail here (ACES then linear→sRGB); without it the frame comes out dark
|
|
625
|
+
// and oversaturated (the volumetric-clouds pipeline bug, root-caused).
|
|
626
|
+
#include <tonemapping_fragment>
|
|
627
|
+
// color grade AFTER tone mapping (values are display-referred now) and
|
|
628
|
+
// BEFORE the colorspace transform: saturation → contrast → vignette
|
|
629
|
+
vec3 graded = gl_FragColor.rgb;
|
|
630
|
+
float luma = dot(graded, vec3(0.2126, 0.7152, 0.0722));
|
|
631
|
+
graded = mix(vec3(luma), graded, uSaturation);
|
|
632
|
+
graded = (graded - 0.5) * uContrast + 0.5;
|
|
633
|
+
float dist = distance(vUv, vec2(0.5));
|
|
634
|
+
graded *= 1.0 - uVignette * smoothstep(0.3, 0.78, dist);
|
|
635
|
+
gl_FragColor.rgb = max(graded, 0.0);
|
|
636
|
+
#include <colorspace_fragment>
|
|
570
637
|
}
|
|
571
638
|
`;
|
|
572
|
-
|
|
573
|
-
* cloud buffer — set `uDir` to (1/w, 0) then (0, 1/h). */
|
|
574
|
-
function createCloudsBlurQuad() {
|
|
639
|
+
function quad(fragment, uniforms) {
|
|
575
640
|
const material = new ShaderMaterial({
|
|
576
|
-
vertexShader:
|
|
577
|
-
fragmentShader:
|
|
641
|
+
vertexShader: VERT,
|
|
642
|
+
fragmentShader: fragment,
|
|
578
643
|
depthTest: false,
|
|
579
644
|
depthWrite: false,
|
|
580
|
-
uniforms
|
|
581
|
-
tTex: { value: null },
|
|
582
|
-
uDir: { value: new Vector2() }
|
|
583
|
-
}
|
|
645
|
+
uniforms
|
|
584
646
|
});
|
|
585
647
|
const mesh = new Mesh(new PlaneGeometry(2, 2), material);
|
|
586
648
|
mesh.frustumCulled = false;
|
|
587
|
-
return
|
|
588
|
-
mesh,
|
|
589
|
-
uniforms: material.uniforms
|
|
590
|
-
};
|
|
649
|
+
return mesh;
|
|
591
650
|
}
|
|
592
|
-
/**
|
|
593
|
-
function
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
depthTest: false,
|
|
598
|
-
depthWrite: false,
|
|
599
|
-
uniforms: {
|
|
600
|
-
tDepth: { value: null },
|
|
601
|
-
uInvViewProj: { value: new Matrix4() },
|
|
602
|
-
uCameraPos: { value: new Vector3() },
|
|
603
|
-
uTime: { value: 0 },
|
|
604
|
-
uSunDir: { value: new Vector3(0, 1, 0) },
|
|
605
|
-
uSunColor: { value: new Color("#fff3da") },
|
|
606
|
-
uCloudColor: { value: new Color("#ffffff") },
|
|
607
|
-
uShadeColor: { value: new Color("#9fb0c8") },
|
|
608
|
-
uHorizonColor: { value: new Color("#cdd9e6") },
|
|
609
|
-
uBase: { value: 120 },
|
|
610
|
-
uTop: { value: 320 },
|
|
611
|
-
uCoverage: { value: .5 },
|
|
612
|
-
uDensity: { value: 1 },
|
|
613
|
-
uScale: { value: 240 },
|
|
614
|
-
uWind: { value: new Vector2(1, .3) },
|
|
615
|
-
uFarFade: { value: 6e3 }
|
|
616
|
-
}
|
|
651
|
+
/** Half-res bright-pass quad — keeps only pixels brighter than the threshold. */
|
|
652
|
+
function createBloomBrightQuad() {
|
|
653
|
+
const mesh = quad(BRIGHT_FRAG, {
|
|
654
|
+
tScene: { value: null },
|
|
655
|
+
uThreshold: { value: 1 }
|
|
617
656
|
});
|
|
618
|
-
const mesh = new Mesh(new PlaneGeometry(2, 2), material);
|
|
619
|
-
mesh.frustumCulled = false;
|
|
620
657
|
return {
|
|
621
658
|
mesh,
|
|
622
|
-
uniforms: material.uniforms
|
|
659
|
+
uniforms: mesh.material.uniforms
|
|
623
660
|
};
|
|
624
661
|
}
|
|
625
|
-
/**
|
|
626
|
-
function
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
depthTest: false,
|
|
631
|
-
depthWrite: false,
|
|
632
|
-
uniforms: {
|
|
633
|
-
tScene: { value: null },
|
|
634
|
-
tClouds: { value: null }
|
|
635
|
-
}
|
|
662
|
+
/** Separable-blur quad (run twice: uDir = (1/w, 0) then (0, 1/h)). */
|
|
663
|
+
function createBloomBlurQuad() {
|
|
664
|
+
const mesh = quad(BLUR_FRAG, {
|
|
665
|
+
tTex: { value: null },
|
|
666
|
+
uDir: { value: new Vector2() }
|
|
636
667
|
});
|
|
637
|
-
const mesh = new Mesh(new PlaneGeometry(2, 2), material);
|
|
638
|
-
mesh.frustumCulled = false;
|
|
639
668
|
return {
|
|
640
669
|
mesh,
|
|
641
|
-
uniforms: material.uniforms
|
|
642
|
-
};
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
const FOG_KEYS = [
|
|
655
|
-
"color",
|
|
656
|
-
"near",
|
|
657
|
-
"far"
|
|
658
|
-
];
|
|
659
|
-
const CLOUD_KEYS = [
|
|
660
|
-
"coverage",
|
|
661
|
-
"density",
|
|
662
|
-
"base",
|
|
663
|
-
"top",
|
|
664
|
-
"color",
|
|
665
|
-
"shadeColor",
|
|
666
|
-
"speed",
|
|
667
|
-
"scale"
|
|
668
|
-
];
|
|
669
|
-
const BLOOM_KEYS = ["threshold", "strength"];
|
|
670
|
-
const POST_KEYS = [
|
|
671
|
-
"vignette",
|
|
672
|
-
"saturation",
|
|
673
|
-
"contrast"
|
|
674
|
-
];
|
|
675
|
-
const SHADOW_KEYS = [
|
|
676
|
-
"mapSize",
|
|
677
|
-
"radius",
|
|
678
|
-
"static"
|
|
679
|
-
];
|
|
680
|
-
const SHADOW_MAP_SIZES = [1024, 2048];
|
|
681
|
-
const DEFAULT_TURBIDITY = 2;
|
|
682
|
-
const DEFAULT_RAYLEIGH = 1;
|
|
683
|
-
const DEFAULT_FOG_NEAR = 50;
|
|
684
|
-
const DEFAULT_FOG_FAR = 800;
|
|
685
|
-
/** Fallback haze when fog is declared without a sky to derive a horizon from. */
|
|
686
|
-
const DEFAULT_FOG_COLOR = "#cfd8e0";
|
|
687
|
-
const DEG2RAD = Math.PI / 180;
|
|
688
|
-
/** Parse + hard-validate the 3D slice of a scene's `environment` header. */
|
|
689
|
-
function parseEnvironment3D(env) {
|
|
670
|
+
uniforms: mesh.material.uniforms
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
/** Full-res composite quad — adds the blurred glow over the scene. */
|
|
674
|
+
function createBloomCompositeQuad() {
|
|
675
|
+
const mesh = quad(COMPOSITE_FRAG, {
|
|
676
|
+
tScene: { value: null },
|
|
677
|
+
tBloom: { value: null },
|
|
678
|
+
uStrength: { value: .85 },
|
|
679
|
+
uVignette: { value: 0 },
|
|
680
|
+
uSaturation: { value: 1 },
|
|
681
|
+
uContrast: { value: 1 }
|
|
682
|
+
});
|
|
690
683
|
return {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
fog: parseFog(env?.fog, env?.sky !== void 0),
|
|
694
|
-
clouds: parseClouds(env?.clouds),
|
|
695
|
-
bloom: parseBloom(env?.bloom),
|
|
696
|
-
post: parsePost(env?.post),
|
|
697
|
-
shadows: parseShadows(env?.shadows)
|
|
684
|
+
mesh,
|
|
685
|
+
uniforms: mesh.material.uniforms
|
|
698
686
|
};
|
|
699
687
|
}
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/3d/clouds/clouds-composite.ts
|
|
700
690
|
/**
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
691
|
+
* Volumetric clouds — a raymarched cloud DECK the camera flies through, drawn as
|
|
692
|
+
* ONE depth-aware fullscreen composite (the engine has no post pipeline, so this
|
|
693
|
+
* mirrors the underwater-caustics pass). It runs only when `environment.clouds`
|
|
694
|
+
* is declared:
|
|
695
|
+
*
|
|
696
|
+
* 1. the renderer renders the scene to an offscreen target (color + depth)
|
|
697
|
+
* 2. this fullscreen material reconstructs each pixel's view RAY from the
|
|
698
|
+
* depth/inverse-view-proj, marches it through a world-space altitude slab
|
|
699
|
+
* [uBase, uTop], accumulates fractal-noise cloud density with cheap
|
|
700
|
+
* single-scatter sun lighting, and composites the result OVER the scene —
|
|
701
|
+
* clipped to the scene depth so clouds behind solid geometry are hidden.
|
|
702
|
+
*
|
|
703
|
+
* Because the clouds live in world space (noise sampled at the marched world
|
|
704
|
+
* position), they have real parallax: they sit still as you bank, grow as you
|
|
705
|
+
* approach, and swallow the screen as you punch through them.
|
|
704
706
|
*/
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
Math.cos(phi),
|
|
711
|
-
Math.sin(phi) * Math.cos(theta)
|
|
712
|
-
];
|
|
707
|
+
const CLOUDS_VERT = `
|
|
708
|
+
varying vec2 vUv;
|
|
709
|
+
void main() {
|
|
710
|
+
vUv = uv;
|
|
711
|
+
gl_Position = vec4(position.xy, 0.0, 1.0);
|
|
713
712
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
713
|
+
`;
|
|
714
|
+
const CLOUDS_FRAG = `
|
|
715
|
+
precision highp float;
|
|
716
|
+
varying vec2 vUv;
|
|
717
|
+
uniform sampler2D tDepth;
|
|
718
|
+
uniform mat4 uInvViewProj;
|
|
719
|
+
uniform vec3 uCameraPos;
|
|
720
|
+
uniform float uTime;
|
|
721
|
+
uniform vec3 uSunDir; // unit, toward the sun
|
|
722
|
+
uniform vec3 uSunColor;
|
|
723
|
+
uniform vec3 uCloudColor; // sunlit
|
|
724
|
+
uniform vec3 uShadeColor; // shaded underside
|
|
725
|
+
uniform vec3 uHorizonColor; // distant haze the clouds fade into
|
|
726
|
+
uniform float uBase;
|
|
727
|
+
uniform float uTop;
|
|
728
|
+
uniform float uCoverage; // 0..1
|
|
729
|
+
uniform float uDensity; // optical thickness multiplier
|
|
730
|
+
uniform float uScale; // feature size (world units)
|
|
731
|
+
uniform vec2 uWind; // drift dir * speed
|
|
732
|
+
uniform float uFarFade; // distance over which clouds melt into haze
|
|
733
|
+
|
|
734
|
+
// ---- value noise + FBM (hash-based, no texture) ----------------------------------
|
|
735
|
+
float hash(vec3 p) {
|
|
736
|
+
p = fract(p * 0.3183099 + 0.1);
|
|
737
|
+
p *= 17.0;
|
|
738
|
+
return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
|
|
723
739
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
];
|
|
735
|
-
const hazy = [
|
|
736
|
-
233,
|
|
737
|
-
228,
|
|
738
|
-
217
|
|
739
|
-
];
|
|
740
|
-
const warm = [
|
|
741
|
-
242,
|
|
742
|
-
201,
|
|
743
|
-
150
|
|
744
|
-
];
|
|
745
|
-
const t = clamp01((sky.turbidity - DEFAULT_TURBIDITY) / 8);
|
|
746
|
-
const dir = sunDirectionFromSky(sky);
|
|
747
|
-
const w = clamp01((18 - Math.asin(clamp(dir[1], -1, 1)) / DEG2RAD) / 18) * .8;
|
|
748
|
-
const mix = (i) => Math.round(lerp(lerp(clear[i], hazy[i], t), warm[i], w));
|
|
749
|
-
return `#${[
|
|
750
|
-
mix(0),
|
|
751
|
-
mix(1),
|
|
752
|
-
mix(2)
|
|
753
|
-
].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
|
|
740
|
+
float vnoise(vec3 x) {
|
|
741
|
+
vec3 i = floor(x);
|
|
742
|
+
vec3 f = fract(x);
|
|
743
|
+
f = f * f * (3.0 - 2.0 * f);
|
|
744
|
+
return mix(
|
|
745
|
+
mix(mix(hash(i + vec3(0,0,0)), hash(i + vec3(1,0,0)), f.x),
|
|
746
|
+
mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
|
|
747
|
+
mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
|
|
748
|
+
mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y),
|
|
749
|
+
f.z);
|
|
754
750
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
751
|
+
float fbm(vec3 p) {
|
|
752
|
+
float s = 0.0;
|
|
753
|
+
float a = 0.5;
|
|
754
|
+
for (int i = 0; i < 3; i++) {
|
|
755
|
+
s += a * vnoise(p);
|
|
756
|
+
p = p * 2.04 + vec3(11.7, 3.1, 5.3);
|
|
757
|
+
a *= 0.5;
|
|
758
|
+
}
|
|
759
|
+
return s;
|
|
759
760
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
761
|
+
|
|
762
|
+
// vertical envelope: wispy base + top, dense middle → cumulus, not a flat slab
|
|
763
|
+
float cloudEnv(vec3 wp) {
|
|
764
|
+
float h = clamp((wp.y - uBase) / (uTop - uBase), 0.0, 1.0);
|
|
765
|
+
return smoothstep(0.0, 0.18, h) * smoothstep(1.0, 0.55, h);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// COARSE density (base FBM only) — used for the sun light-march, which doesn't
|
|
769
|
+
// need the fine erosion detail, so each lit step stays cheap (one fbm).
|
|
770
|
+
float cloudDensityCoarse(vec3 wp) {
|
|
771
|
+
float env = cloudEnv(wp);
|
|
772
|
+
if (env <= 0.0) return 0.0;
|
|
773
|
+
vec3 q = wp / uScale;
|
|
774
|
+
q.xz += uWind * uTime * 0.03;
|
|
775
|
+
return max(0.0, fbm(q) - (1.0 - uCoverage)) * env * 2.0;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// FULL density: coarse shape eroded by two higher-frequency layers → crisp
|
|
779
|
+
// billowy cauliflower edges instead of soft amorphous blobs.
|
|
780
|
+
float cloudDensity(vec3 wp) {
|
|
781
|
+
float env = cloudEnv(wp);
|
|
782
|
+
if (env <= 0.0) return 0.0;
|
|
783
|
+
vec3 q = wp / uScale;
|
|
784
|
+
q.xz += uWind * uTime * 0.03;
|
|
785
|
+
float d = fbm(q) - (1.0 - uCoverage);
|
|
786
|
+
if (d <= 0.0) return 0.0; // empty space — skip the erosion noise (cheap)
|
|
787
|
+
d -= 0.30 * vnoise(q * 3.2 + 4.0);
|
|
788
|
+
d -= 0.14 * vnoise(q * 8.5 + 9.0);
|
|
789
|
+
return max(0.0, d) * env * 2.0;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
void main() {
|
|
793
|
+
float depth = texture2D(tDepth, vUv).x;
|
|
794
|
+
|
|
795
|
+
// view ray (world space) for this pixel
|
|
796
|
+
vec4 farNdc = vec4(vUv * 2.0 - 1.0, 1.0, 1.0);
|
|
797
|
+
vec4 farW = uInvViewProj * farNdc;
|
|
798
|
+
farW.xyz /= farW.w;
|
|
799
|
+
vec3 ro = uCameraPos;
|
|
800
|
+
vec3 rd = normalize(farW.xyz - ro);
|
|
801
|
+
|
|
802
|
+
// distance to the nearest solid surface along the ray (sky = far away)
|
|
803
|
+
float sceneDist = 1e9;
|
|
804
|
+
if (depth < 1.0) {
|
|
805
|
+
vec4 ndc = vec4(vUv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
|
|
806
|
+
vec4 wp = uInvViewProj * ndc;
|
|
807
|
+
wp.xyz /= wp.w;
|
|
808
|
+
sceneDist = distance(wp.xyz, ro);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// intersect the ray with the altitude slab [uBase, uTop]
|
|
812
|
+
float t0 = 0.0;
|
|
813
|
+
float t1 = uFarFade * 1.6;
|
|
814
|
+
if (abs(rd.y) > 1e-4) {
|
|
815
|
+
float ta = (uBase - ro.y) / rd.y;
|
|
816
|
+
float tb = (uTop - ro.y) / rd.y;
|
|
817
|
+
t0 = max(t0, min(ta, tb));
|
|
818
|
+
t1 = min(t1, max(ta, tb));
|
|
819
|
+
} else {
|
|
820
|
+
// ray nearly parallel to the slab: only valid if we're inside it
|
|
821
|
+
if (ro.y < uBase || ro.y > uTop) { gl_FragColor = vec4(0.0); return; }
|
|
822
|
+
}
|
|
823
|
+
t1 = min(t1, sceneDist);
|
|
824
|
+
if (t1 <= t0) { gl_FragColor = vec4(0.0); return; }
|
|
825
|
+
|
|
826
|
+
// raymarch (capped step count; clamp the marched span so steps stay dense)
|
|
827
|
+
// Break step-aliasing rings with INTERLEAVED GRADIENT NOISE (IGN) rather than a
|
|
828
|
+
// Bayer matrix (which upscaled into a regular halftone GRID) or no dither (which
|
|
829
|
+
// left banding RINGS). IGN is high-quality de-correlated noise that, softened by
|
|
830
|
+
// the composite blur, dissolves into smooth cloud — affordable at a modest step
|
|
831
|
+
// count instead of brute-forcing steps (which blew the frame budget).
|
|
832
|
+
const int STEPS = 32;
|
|
833
|
+
float span = min(t1 - t0, 2000.0);
|
|
834
|
+
float step = span / float(STEPS);
|
|
835
|
+
float ign = fract(52.9829189 * fract(0.06711056 * gl_FragCoord.x + 0.00583715 * gl_FragCoord.y));
|
|
836
|
+
float t = t0 + step * ign;
|
|
837
|
+
float trans = 1.0;
|
|
838
|
+
vec3 accum = vec3(0.0);
|
|
839
|
+
vec3 litColor = mix(vec3(1.0), uSunColor, 0.35) * uCloudColor; // mostly-white
|
|
840
|
+
// EXT = per-metre extinction. step is in metres, so without this the optical
|
|
841
|
+
// depth per step is enormous (one step → solid white blobs). ~0.03/m + the
|
|
842
|
+
// uDensity dial gives clouds that build up gradually and keep their FORM.
|
|
843
|
+
float ext = 0.03 * uDensity;
|
|
844
|
+
for (int i = 0; i < STEPS; i++) {
|
|
845
|
+
if (trans < 0.02) break;
|
|
846
|
+
vec3 p = ro + rd * t;
|
|
847
|
+
float d = cloudDensity(p);
|
|
848
|
+
if (d > 0.001) {
|
|
849
|
+
// single-scatter: ONE coarse tap toward the sun (cheap — keeps per-step cost
|
|
850
|
+
// low) → sunlit crests vs softly shaded sides. Clouds multi-scatter a LOT, so
|
|
851
|
+
// shadows are gentle (low extinction *1.5) and floored bright (0.45..1) — real
|
|
852
|
+
// cumulus from above are mostly white, not dark-grey blotches.
|
|
853
|
+
float sunTau = cloudDensityCoarse(p + uSunDir * 110.0) * 110.0 * ext;
|
|
854
|
+
float light = mix(0.45, 1.0, exp(-sunTau * 1.5));
|
|
855
|
+
vec3 col = mix(uShadeColor, litColor, light);
|
|
856
|
+
float a = d * ext * step; // optical depth this step
|
|
857
|
+
float w = (1.0 - exp(-a)) * trans; // energy scattered toward the eye
|
|
858
|
+
accum += col * w;
|
|
859
|
+
trans *= exp(-a);
|
|
860
|
+
}
|
|
861
|
+
t += step;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
float alpha = clamp(1.0 - trans, 0.0, 1.0);
|
|
865
|
+
// Melt distant clouds fully into the horizon haze BEFORE they smear: looking
|
|
866
|
+
// edge-on through the thin slab, far samples streak — so fade the whole cloud
|
|
867
|
+
// contribution out over distance and drop alpha to 0 by the far edge.
|
|
868
|
+
float distFade = 1.0 - smoothstep(uFarFade * 0.25, uFarFade * 0.7, t0);
|
|
869
|
+
accum = mix(uHorizonColor * alpha, accum, distFade);
|
|
870
|
+
alpha *= distFade;
|
|
871
|
+
|
|
872
|
+
// output PREMULTIPLIED cloud color + coverage — composited over the scene in
|
|
873
|
+
// a separate full-res pass (this raymarch runs at HALF res for performance)
|
|
874
|
+
gl_FragColor = vec4(accum, alpha);
|
|
801
875
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
876
|
+
`;
|
|
877
|
+
/** Separable Gaussian blur on the low-res cloud buffer — run twice (H then V).
|
|
878
|
+
* Wide enough to fully dissolve the raymarch dither (incl. IGN's diagonal grain)
|
|
879
|
+
* into smooth cloud; cheap because it runs at the low cloud-buffer resolution. */
|
|
880
|
+
const CLOUDS_BLUR_FRAG = `
|
|
881
|
+
precision highp float;
|
|
882
|
+
varying vec2 vUv;
|
|
883
|
+
uniform sampler2D tTex;
|
|
884
|
+
uniform vec2 uDir; // (texel, 0) horizontal pass, then (0, texel) vertical
|
|
885
|
+
void main() {
|
|
886
|
+
// 9-tap Gaussian (sigma ~2.2 texels) — weights sum to 1
|
|
887
|
+
vec4 c = texture2D(tTex, vUv) * 0.20;
|
|
888
|
+
c += texture2D(tTex, vUv + uDir * 1.0) * 0.166;
|
|
889
|
+
c += texture2D(tTex, vUv - uDir * 1.0) * 0.166;
|
|
890
|
+
c += texture2D(tTex, vUv + uDir * 2.0) * 0.096;
|
|
891
|
+
c += texture2D(tTex, vUv - uDir * 2.0) * 0.096;
|
|
892
|
+
c += texture2D(tTex, vUv + uDir * 3.0) * 0.04;
|
|
893
|
+
c += texture2D(tTex, vUv - uDir * 3.0) * 0.04;
|
|
894
|
+
c += texture2D(tTex, vUv + uDir * 4.0) * 0.015;
|
|
895
|
+
c += texture2D(tTex, vUv - uDir * 4.0) * 0.015;
|
|
896
|
+
gl_FragColor = c;
|
|
820
897
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
const density = numberOr(c.density, 1, "clouds.density");
|
|
833
|
-
if (density < 0) throw new IncantoError("BAD_FORMAT", `environment.clouds.density must be >= 0 (optical thickness), got ${density}.`, { prop: "clouds" });
|
|
834
|
-
const base = numberOr(c.base, 120, "clouds.base");
|
|
835
|
-
const top = numberOr(c.top, 320, "clouds.top");
|
|
836
|
-
if (top <= base) throw new IncantoError("BAD_FORMAT", `environment.clouds.top must be > base (got base ${base}, top ${top}).`, { prop: "clouds" });
|
|
837
|
-
const speed = numberOr(c.speed, 1, "clouds.speed");
|
|
838
|
-
const scale = numberOr(c.scale, 240, "clouds.scale");
|
|
839
|
-
if (scale <= 0) throw new IncantoError("BAD_FORMAT", `environment.clouds.scale must be > 0 (feature size in world units), got ${scale}.`, { prop: "clouds" });
|
|
840
|
-
return {
|
|
841
|
-
coverage,
|
|
842
|
-
density,
|
|
843
|
-
base,
|
|
844
|
-
top,
|
|
845
|
-
color: c.color ?? "#ffffff",
|
|
846
|
-
shadeColor: c.shadeColor ?? "#9fb0c8",
|
|
847
|
-
speed,
|
|
848
|
-
scale
|
|
849
|
-
};
|
|
898
|
+
`;
|
|
899
|
+
/** Composite pass: blend the (pre-blurred) low-res cloud buffer over the scene. */
|
|
900
|
+
const CLOUDS_COMPOSITE_FRAG = `
|
|
901
|
+
precision highp float;
|
|
902
|
+
varying vec2 vUv;
|
|
903
|
+
uniform sampler2D tScene;
|
|
904
|
+
uniform sampler2D tClouds;
|
|
905
|
+
void main() {
|
|
906
|
+
vec4 scene = texture2D(tScene, vUv);
|
|
907
|
+
vec4 cloud = texture2D(tClouds, vUv); // already separable-blurred + premultiplied
|
|
908
|
+
gl_FragColor = vec4(scene.rgb * (1.0 - cloud.a) + cloud.rgb, 1.0);
|
|
850
909
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
910
|
+
`;
|
|
911
|
+
/** Separable-blur quad (run twice: horizontal then vertical) over the low-res
|
|
912
|
+
* cloud buffer — set `uDir` to (1/w, 0) then (0, 1/h). */
|
|
913
|
+
function createCloudsBlurQuad() {
|
|
914
|
+
const material = new ShaderMaterial({
|
|
915
|
+
vertexShader: CLOUDS_VERT,
|
|
916
|
+
fragmentShader: CLOUDS_BLUR_FRAG,
|
|
917
|
+
depthTest: false,
|
|
918
|
+
depthWrite: false,
|
|
919
|
+
uniforms: {
|
|
920
|
+
tTex: { value: null },
|
|
921
|
+
uDir: { value: new Vector2() }
|
|
922
|
+
}
|
|
858
923
|
});
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
const strength = numberOr(b.strength, .8, "bloom.strength");
|
|
862
|
-
if (strength < 0) throw new IncantoError("BAD_FORMAT", `environment.bloom.strength must be >= 0, got ${strength}.`, { prop: "bloom" });
|
|
924
|
+
const mesh = new Mesh(new PlaneGeometry(2, 2), material);
|
|
925
|
+
mesh.frustumCulled = false;
|
|
863
926
|
return {
|
|
864
|
-
|
|
865
|
-
|
|
927
|
+
mesh,
|
|
928
|
+
uniforms: material.uniforms
|
|
866
929
|
};
|
|
867
930
|
}
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
931
|
+
/** The half-res raymarch quad — writes premultiplied cloud color + coverage. */
|
|
932
|
+
function createCloudsQuad() {
|
|
933
|
+
const material = new ShaderMaterial({
|
|
934
|
+
vertexShader: CLOUDS_VERT,
|
|
935
|
+
fragmentShader: CLOUDS_FRAG,
|
|
936
|
+
depthTest: false,
|
|
937
|
+
depthWrite: false,
|
|
938
|
+
uniforms: {
|
|
939
|
+
tDepth: { value: null },
|
|
940
|
+
uInvViewProj: { value: new Matrix4() },
|
|
941
|
+
uCameraPos: { value: new Vector3() },
|
|
942
|
+
uTime: { value: 0 },
|
|
943
|
+
uSunDir: { value: new Vector3(0, 1, 0) },
|
|
944
|
+
uSunColor: { value: new Color("#fff3da") },
|
|
945
|
+
uCloudColor: { value: new Color("#ffffff") },
|
|
946
|
+
uShadeColor: { value: new Color("#9fb0c8") },
|
|
947
|
+
uHorizonColor: { value: new Color("#cdd9e6") },
|
|
948
|
+
uBase: { value: 120 },
|
|
949
|
+
uTop: { value: 320 },
|
|
950
|
+
uCoverage: { value: .5 },
|
|
951
|
+
uDensity: { value: 1 },
|
|
952
|
+
uScale: { value: 240 },
|
|
953
|
+
uWind: { value: new Vector2(1, .3) },
|
|
954
|
+
uFarFade: { value: 6e3 }
|
|
955
|
+
}
|
|
875
956
|
});
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
const saturation = numberOr(p.saturation, 1, "post.saturation");
|
|
879
|
-
if (saturation < 0 || saturation > 4) throw new IncantoError("BAD_FORMAT", `environment.post.saturation must be in [0, 4] (1 = neutral), got ${saturation}.`, { prop: "post" });
|
|
880
|
-
const contrast = numberOr(p.contrast, 1, "post.contrast");
|
|
881
|
-
if (contrast < .2 || contrast > 3) throw new IncantoError("BAD_FORMAT", `environment.post.contrast must be in [0.2, 3] (1 = neutral), got ${contrast}.`, { prop: "post" });
|
|
957
|
+
const mesh = new Mesh(new PlaneGeometry(2, 2), material);
|
|
958
|
+
mesh.frustumCulled = false;
|
|
882
959
|
return {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
contrast
|
|
960
|
+
mesh,
|
|
961
|
+
uniforms: material.uniforms
|
|
886
962
|
};
|
|
887
963
|
}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
prop: "shadows",
|
|
900
|
-
validOptions: SHADOW_KEYS
|
|
901
|
-
});
|
|
902
|
-
const mapSize = shadows.mapSize === void 0 ? 2048 : shadows.mapSize;
|
|
903
|
-
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)}.`, {
|
|
904
|
-
prop: "shadows",
|
|
905
|
-
validOptions: SHADOW_MAP_SIZES.map(String)
|
|
964
|
+
/** The full-res composite quad — blends the half-res cloud buffer over the scene. */
|
|
965
|
+
function createCloudsCompositeQuad() {
|
|
966
|
+
const material = new ShaderMaterial({
|
|
967
|
+
vertexShader: CLOUDS_VERT,
|
|
968
|
+
fragmentShader: CLOUDS_COMPOSITE_FRAG,
|
|
969
|
+
depthTest: false,
|
|
970
|
+
depthWrite: false,
|
|
971
|
+
uniforms: {
|
|
972
|
+
tScene: { value: null },
|
|
973
|
+
tClouds: { value: null }
|
|
974
|
+
}
|
|
906
975
|
});
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
const staticShadows = shadows.static === void 0 ? false : shadows.static;
|
|
910
|
-
if (typeof staticShadows !== "boolean") throw new IncantoError("BAD_FORMAT", `environment.shadows.static must be a boolean, got ${JSON.stringify(shadows.static)}.`, { prop: "shadows" });
|
|
976
|
+
const mesh = new Mesh(new PlaneGeometry(2, 2), material);
|
|
977
|
+
mesh.frustumCulled = false;
|
|
911
978
|
return {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
static: staticShadows
|
|
979
|
+
mesh,
|
|
980
|
+
uniforms: material.uniforms
|
|
915
981
|
};
|
|
916
982
|
}
|
|
917
|
-
function numberOr(value, fallback, at) {
|
|
918
|
-
if (value === void 0) return fallback;
|
|
919
|
-
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 });
|
|
920
|
-
return value;
|
|
921
|
-
}
|
|
922
|
-
function clamp(v, min, max) {
|
|
923
|
-
return Math.min(Math.max(v, min), max);
|
|
924
|
-
}
|
|
925
|
-
function clamp01(v) {
|
|
926
|
-
return clamp(v, 0, 1);
|
|
927
|
-
}
|
|
928
|
-
function lerp(a, b, t) {
|
|
929
|
-
return a + (b - a) * t;
|
|
930
|
-
}
|
|
931
983
|
//#endregion
|
|
932
984
|
//#region src/3d/environment-3d.ts
|
|
933
985
|
/** Shadow ortho half-extent when the env header forces a light to cast and the
|
|
@@ -1566,8 +1618,8 @@ var Renderer3D = class {
|
|
|
1566
1618
|
this.ownsAssets = !opts.assets;
|
|
1567
1619
|
this.assets = opts.assets ?? new AssetStore3D(this.engine);
|
|
1568
1620
|
const rendering = resolveRendering(opts.engine.scene?.environment, {
|
|
1569
|
-
antialias: true,
|
|
1570
|
-
pixelRatio: Math.min(globalThis.devicePixelRatio ?? 1, 2),
|
|
1621
|
+
antialias: opts.qualityDefaults?.antialias ?? true,
|
|
1622
|
+
pixelRatio: opts.qualityDefaults?.pixelRatio ?? Math.min(globalThis.devicePixelRatio ?? 1, 2),
|
|
1571
1623
|
preserveDrawingBuffer: false
|
|
1572
1624
|
}, globalThis.devicePixelRatio ?? 1, {
|
|
1573
1625
|
pixelRatio: opts.pixelRatio,
|
|
@@ -1609,6 +1661,8 @@ var Renderer3D = class {
|
|
|
1609
1661
|
/** The pixel ratio the scene ASKED for — the governor scales this, never replaces it. */
|
|
1610
1662
|
basePixelRatio;
|
|
1611
1663
|
adaptive;
|
|
1664
|
+
renderScale = 1;
|
|
1665
|
+
adaptiveScale = 1;
|
|
1612
1666
|
lastFrameAt = 0;
|
|
1613
1667
|
debugLines;
|
|
1614
1668
|
selectionLines;
|
|
@@ -1654,7 +1708,32 @@ var Renderer3D = class {
|
|
|
1654
1708
|
}
|
|
1655
1709
|
const scale = gov.push(frameMs);
|
|
1656
1710
|
if (scale === null) return;
|
|
1657
|
-
this.
|
|
1711
|
+
this.adaptiveScale = scale;
|
|
1712
|
+
this.applyPixelRatio();
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* The player's resolution multiplier, on top of whatever the scene and the
|
|
1716
|
+
* quality tier resolved to.
|
|
1717
|
+
*
|
|
1718
|
+
* The cheapest lever there is on a weak GPU: at 0.66 the fragment shaders run
|
|
1719
|
+
* over 44% of the pixels for a soft image and nothing else, where dropping a
|
|
1720
|
+
* quality tier costs the shadows outright. Live — a settings menu writes it
|
|
1721
|
+
* mid-game — and it multiplies with the adaptive governor rather than
|
|
1722
|
+
* fighting it, so a player who has already scaled down does not get scaled
|
|
1723
|
+
* down twice as hard.
|
|
1724
|
+
*/
|
|
1725
|
+
setRenderScale(scale) {
|
|
1726
|
+
const next = Math.min(2, Math.max(.25, scale > 0 ? scale : 1));
|
|
1727
|
+
if (next === this.renderScale) return;
|
|
1728
|
+
this.renderScale = next;
|
|
1729
|
+
this.applyPixelRatio();
|
|
1730
|
+
}
|
|
1731
|
+
/** Judge frames against `fps` — see AdaptiveResolution.setTarget. */
|
|
1732
|
+
setFrameTarget(fps) {
|
|
1733
|
+
this.adaptive?.setTarget(fps);
|
|
1734
|
+
}
|
|
1735
|
+
applyPixelRatio() {
|
|
1736
|
+
this.webgl.setPixelRatio(this.basePixelRatio * this.renderScale * this.adaptiveScale);
|
|
1658
1737
|
const size = this.lastSize;
|
|
1659
1738
|
if (size.w > 0 && size.h > 0) this.webgl.setSize(size.w, size.h, false);
|
|
1660
1739
|
}
|
|
@@ -2210,9 +2289,14 @@ async function createGame3D(opts) {
|
|
|
2210
2289
|
});
|
|
2211
2290
|
}
|
|
2212
2291
|
}
|
|
2292
|
+
const tierRendering = qualityRendering(engine.settings.autoQuality());
|
|
2213
2293
|
const renderer = opts._rendererFactory ? opts._rendererFactory(engine, opts.canvas) : new Renderer3D({
|
|
2214
2294
|
canvas: opts.canvas,
|
|
2215
2295
|
engine,
|
|
2296
|
+
qualityDefaults: {
|
|
2297
|
+
antialias: tierRendering.antialias,
|
|
2298
|
+
pixelRatio: tierRendering.pixelRatio
|
|
2299
|
+
},
|
|
2216
2300
|
...opts.pixelRatio !== void 0 ? { pixelRatio: opts.pixelRatio } : {},
|
|
2217
2301
|
...opts.adaptiveResolution !== void 0 ? { adaptiveResolution: opts.adaptiveResolution } : {},
|
|
2218
2302
|
...opts.preserveDrawingBuffer !== void 0 ? { preserveDrawingBuffer: opts.preserveDrawingBuffer } : {}
|
|
@@ -2277,6 +2361,16 @@ async function createGame3D(opts) {
|
|
|
2277
2361
|
} catch {}
|
|
2278
2362
|
cleanups.push(engine.settings.bindAudio(engine.audio));
|
|
2279
2363
|
cleanups.push(engine.settings.bindLocale(engine.locale));
|
|
2364
|
+
cleanups.push(engine.settings.bindQuality((patch) => {
|
|
2365
|
+
setEnvironment3D(engine, patch);
|
|
2366
|
+
}));
|
|
2367
|
+
cleanups.push(engine.settings.bindFrameCap((fps) => {
|
|
2368
|
+
engine.maxFps = fps;
|
|
2369
|
+
renderer.setFrameTarget?.(fps || 60);
|
|
2370
|
+
}));
|
|
2371
|
+
cleanups.push(engine.settings.bindRenderScale((scale) => {
|
|
2372
|
+
renderer.setRenderScale?.(scale);
|
|
2373
|
+
}));
|
|
2280
2374
|
await report(1, "ready");
|
|
2281
2375
|
function disposeGame() {
|
|
2282
2376
|
teardown([
|
|
@@ -2363,4 +2457,4 @@ function wireAudioUnlock(engine, root, canvas) {
|
|
|
2363
2457
|
return detachAll;
|
|
2364
2458
|
}
|
|
2365
2459
|
//#endregion
|
|
2366
|
-
export { Environment3D as a,
|
|
2460
|
+
export { Environment3D as a, parseEnvironment3D as c, AssetStore3D as d, syncTree as i, sunDirectionFromElevationAzimuth as l, create_game_exports as n, setEnvironment3D as o, Renderer3D as r, horizonColorFromSky as s, createGame3D as t, sunDirectionFromSky as u };
|