@call-me-sensei/toonlab 0.1.1 → 0.2.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/AGENTS.md +131 -0
- package/README.md +73 -0
- package/package.json +3 -2
- package/src/debrisgen/debrisPresets.js +7 -0
- package/src/environment/environmentPresets.js +19 -0
- package/src/environment/environmentSettings.js +10 -0
- package/src/environment/environmentTextureResolver.js +3 -0
- package/src/environment/index.js +1 -0
- package/src/index.js +7 -0
- package/src/post/postProcessing.js +28 -0
- package/src/rockgen/rockgenPresets.js +8 -0
- package/src/rockgen/rockgenSettings.js +47 -0
- package/src/shaders-tsl/environment.js +60 -0
- package/src/shaders-tsl/water.js +54 -1
- package/src/sky/stylizedSky.js +49 -1
- package/src/stylizedTerrain.js +601 -0
- package/src/stylizedWorld.js +500 -0
- package/src/vegetation/index.js +2 -0
- package/src/vegetation/scatter.js +297 -0
- package/src/vegetation/stylizedFlowers.js +48 -1
- package/src/vegetation/stylizedForest.js +448 -0
- package/src/vegetation/stylizedGrass.js +50 -2
- package/src/vegetation/stylizedTree.js +71 -2
- package/src/vegetation/stylizedTreeFoliage.js +11 -2
- package/src/water/waterScenePasses.js +5 -1
- package/src/water/waterSettings.js +10 -0
- package/src/water/waterSurface.js +27 -0
- package/src/worldCollision.js +94 -0
- package/src/worldMinimap.js +152 -0
- package/src/worldPresets.js +122 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applyEnvironmentShader,
|
|
5
|
+
advanceEnvironmentShaderTime,
|
|
6
|
+
setEnvironmentCloudShadow,
|
|
7
|
+
} from './environment/environmentMaterialAdapter.js';
|
|
8
|
+
import { resolveEnvironmentPreset } from './environment/environmentPresets.js';
|
|
9
|
+
import { createEnvironmentSunRig } from './environment/environmentRigs.js';
|
|
10
|
+
import { createEnvironmentSunShadowPass } from './environment/environmentSunShadowPass.js';
|
|
11
|
+
import { WaterSurface } from './water/waterSurface.js';
|
|
12
|
+
import { StylizedSky } from './sky/stylizedSky.js';
|
|
13
|
+
import { StylizedGrassField } from './vegetation/stylizedGrass.js';
|
|
14
|
+
import { StylizedFlowerField } from './vegetation/stylizedFlowers.js';
|
|
15
|
+
import { StylizedForest } from './vegetation/stylizedForest.js';
|
|
16
|
+
import {
|
|
17
|
+
combineMasks,
|
|
18
|
+
createSlopeMask,
|
|
19
|
+
createWaterMask,
|
|
20
|
+
scatterForest,
|
|
21
|
+
scatterGrassAround,
|
|
22
|
+
} from './vegetation/scatter.js';
|
|
23
|
+
import { resolveWorldPreset } from './worldPresets.js';
|
|
24
|
+
import { createWorldCollision } from './worldCollision.js';
|
|
25
|
+
|
|
26
|
+
// The composed golden path: one call assembles a complete stylized outdoor
|
|
27
|
+
// world — environment-shaded terrain, sun rig, sky dome, interactive water,
|
|
28
|
+
// scattered trees/grass/flowers, shared cloud shadows — from a world preset,
|
|
29
|
+
// and returns a single update(delta) for the render loop. Import from
|
|
30
|
+
// '@call-me-sensei/toonlab'.
|
|
31
|
+
//
|
|
32
|
+
// The look of this library does not live in any single system; it emerges
|
|
33
|
+
// from the assembly (the sky feeds the water's reflections, the sun rig and
|
|
34
|
+
// fog set the palette, cloud shadows tie ground/canopy/water together). This
|
|
35
|
+
// function IS that assembly, so a host — human or coding agent — cannot
|
|
36
|
+
// accidentally ship the geometry without the look.
|
|
37
|
+
//
|
|
38
|
+
// const world = await createStylizedWorld({
|
|
39
|
+
// renderer, scene, camera,
|
|
40
|
+
// terrain: { root: terrainMesh, heightAt, size: { width: 2000, depth: 2000 } },
|
|
41
|
+
// water: { level: 8 },
|
|
42
|
+
// followTarget: characterRoot, // ripples, wakes, grass push-away
|
|
43
|
+
// });
|
|
44
|
+
//
|
|
45
|
+
// // render loop:
|
|
46
|
+
// world.update(delta);
|
|
47
|
+
// renderer.render(scene, camera); // or post.render(delta)
|
|
48
|
+
//
|
|
49
|
+
// Character/toon shading stays host-owned (applyToonShader +
|
|
50
|
+
// createCharacterRenderPasses), as does post-processing — both compose with
|
|
51
|
+
// this world untouched.
|
|
52
|
+
|
|
53
|
+
function cleanObject(value) {
|
|
54
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Assembles a stylized outdoor world from a world preset.
|
|
59
|
+
*
|
|
60
|
+
* @param {Object} options
|
|
61
|
+
* @param {THREE.Renderer} options.renderer Host renderer (water passes need it).
|
|
62
|
+
* @param {THREE.Scene} options.scene Host scene; systems are added to it.
|
|
63
|
+
* @param {THREE.PerspectiveCamera} [options.camera] When given (and
|
|
64
|
+
* `applyCamera` isn't false), the preset's near/far/fov are applied.
|
|
65
|
+
* @param {Object} options.terrain `{ root, heightAt, size }` — `root` is the
|
|
66
|
+
* Object3D converted by the environment shader (required); `heightAt(x, z)`
|
|
67
|
+
* drives water bed, scatter placement, and slope/water masks (strongly
|
|
68
|
+
* recommended); `size` is `{ width, depth }` in meters for water extent and
|
|
69
|
+
* scatter radius defaults.
|
|
70
|
+
* @param {string} [options.preset] World preset name (default 'outdoorGameplay').
|
|
71
|
+
* @param {Object|false} [options.water] `{ level, width, depth, settings }`,
|
|
72
|
+
* or `false` to skip water.
|
|
73
|
+
* @param {Object|false} [options.trees] `{ scatter, settings, center }`
|
|
74
|
+
* overrides over the world preset, or `false` to skip.
|
|
75
|
+
* @param {Object|false} [options.grass] Same shape as trees, or `false`.
|
|
76
|
+
* @param {Object|false} [options.flowers] `{ scatter, settings }`; off unless given.
|
|
77
|
+
* @param {Object3D} [options.followTarget] Character root: water ripple
|
|
78
|
+
* window + interactor splashes/wakes, grass push-away.
|
|
79
|
+
* @param {Object|false} [options.cloudShadows] `{ strength, coverage, scale,
|
|
80
|
+
* velocity }` shared across terrain, water, grass, and trees.
|
|
81
|
+
* @param {boolean} [options.sun] Build the environment sun rig (default true).
|
|
82
|
+
* @param {boolean} [options.applyCamera] Apply preset camera planes (default true).
|
|
83
|
+
* @param {Object} [options.environment] Extra `applyEnvironmentShader`
|
|
84
|
+
* options merged last (features/parameters/roleOverrides/...).
|
|
85
|
+
* @returns {Promise<Object>} `{ update, dispose, sky, water, sunRig, grass,
|
|
86
|
+
* flowerField, trees, treeGroup, masks, worldPreset, classification }`
|
|
87
|
+
*/
|
|
88
|
+
export async function createStylizedWorld({
|
|
89
|
+
renderer,
|
|
90
|
+
scene,
|
|
91
|
+
camera = null,
|
|
92
|
+
terrain,
|
|
93
|
+
preset = 'outdoorGameplay',
|
|
94
|
+
water = {},
|
|
95
|
+
sky: skyOptions = {},
|
|
96
|
+
fog = {},
|
|
97
|
+
shadows = {},
|
|
98
|
+
trees = {},
|
|
99
|
+
grass = {},
|
|
100
|
+
flowers = false,
|
|
101
|
+
followTarget = null,
|
|
102
|
+
cloudShadows = { strength: 0.35 },
|
|
103
|
+
sun = true,
|
|
104
|
+
applyCamera = true,
|
|
105
|
+
environment = {},
|
|
106
|
+
} = {}) {
|
|
107
|
+
if (!renderer || !scene) throw new Error('createStylizedWorld needs { renderer, scene }.');
|
|
108
|
+
const terrainRoot = terrain?.root ?? null;
|
|
109
|
+
if (!terrainRoot) throw new Error('createStylizedWorld needs terrain.root (the Object3D to shade).');
|
|
110
|
+
const heightAt = typeof terrain?.heightAt === 'function' ? terrain.heightAt : null;
|
|
111
|
+
|
|
112
|
+
const worldPreset = resolveWorldPreset(preset);
|
|
113
|
+
if (!worldPreset) throw new Error(`Unknown world preset "${preset}".`);
|
|
114
|
+
|
|
115
|
+
// terrain.size accepts a number (square), { width, depth }, or { x, z }
|
|
116
|
+
// (the shape createStylizedTerrain returns as meshExtent) — silently
|
|
117
|
+
// defaulting on an unrecognized shape mis-sizes the water plane.
|
|
118
|
+
const sizeOption = terrain?.size;
|
|
119
|
+
const width = Number(sizeOption?.width ?? sizeOption?.x ?? sizeOption) || 2000;
|
|
120
|
+
const depth = Number(sizeOption?.depth ?? sizeOption?.z ?? sizeOption) || 2000;
|
|
121
|
+
const halfExtent = Math.min(width, depth) / 2;
|
|
122
|
+
|
|
123
|
+
// Camera planes and fov are part of the scale contract.
|
|
124
|
+
if (camera && applyCamera !== false && worldPreset.camera) {
|
|
125
|
+
if (Number.isFinite(worldPreset.camera.fov)) camera.fov = worldPreset.camera.fov;
|
|
126
|
+
if (Number.isFinite(worldPreset.camera.near)) camera.near = worldPreset.camera.near;
|
|
127
|
+
if (Number.isFinite(worldPreset.camera.far)) camera.far = worldPreset.camera.far;
|
|
128
|
+
camera.updateProjectionMatrix();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Environment shading over the terrain (and anything parented under it).
|
|
132
|
+
const environmentBox = new THREE.Box3().setFromObject(terrainRoot);
|
|
133
|
+
const environmentPreset = resolveEnvironmentPreset(worldPreset.environment?.preset ?? 'default') ?? {};
|
|
134
|
+
const environmentOverrides = cleanObject(worldPreset.environment?.overrides);
|
|
135
|
+
const classification = await applyEnvironmentShader(terrainRoot, {
|
|
136
|
+
...environmentPreset,
|
|
137
|
+
...environmentOverrides,
|
|
138
|
+
...cleanObject(environment),
|
|
139
|
+
environmentBox,
|
|
140
|
+
hasSun: sun !== false,
|
|
141
|
+
parameters: {
|
|
142
|
+
...cleanObject(environmentPreset.parameters),
|
|
143
|
+
...cleanObject(environmentOverrides.parameters),
|
|
144
|
+
...cleanObject(cleanObject(environment).parameters),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Keep the light rig aligned with the sky's visible sun — a sky sun in one
|
|
149
|
+
// place and light from another reads flat and wrong, and near-vertical
|
|
150
|
+
// light hides every cast shadow under its caster.
|
|
151
|
+
const skySunDirection = cleanObject(cleanObject(skyOptions).settings).sunDirection
|
|
152
|
+
?? cleanObject(worldPreset.sky?.settings).sunDirection;
|
|
153
|
+
// Unified atmosphere: scene.fog reaches every material class — converted
|
|
154
|
+
// environment surfaces, unlit forest impostors, rock clones — so distant
|
|
155
|
+
// geometry fades into haze silhouettes together. (The environment
|
|
156
|
+
// shader's height fog layers altitude nuance on top; without scene.fog,
|
|
157
|
+
// impostor trees float sharp and saturated at any distance.)
|
|
158
|
+
let sceneFog = null;
|
|
159
|
+
if (fog !== false) {
|
|
160
|
+
const fogOptions = cleanObject(fog);
|
|
161
|
+
const fogColor = fogOptions.color
|
|
162
|
+
?? cleanObject(environmentOverrides.parameters).heightFogColor
|
|
163
|
+
?? cleanObject(cleanObject(environment).parameters).heightFogColor
|
|
164
|
+
?? [0.72, 0.83, 0.94];
|
|
165
|
+
const presetFar = Number(worldPreset.camera?.far) || 600;
|
|
166
|
+
sceneFog = new THREE.Fog(
|
|
167
|
+
new THREE.Color(...(Array.isArray(fogColor) ? fogColor : [fogColor])),
|
|
168
|
+
Number(fogOptions.near) || presetFar * 0.3,
|
|
169
|
+
Number(fogOptions.far) || presetFar * 1.4,
|
|
170
|
+
);
|
|
171
|
+
scene.fog = sceneFog;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Resolved height-fog parameters (preset overrides, then host overrides).
|
|
175
|
+
// Water and forest impostors mirror this layer — any surface that skips it
|
|
176
|
+
// stays sharp against hazed terrain and reads as pasted-on.
|
|
177
|
+
const heightFogParams = {
|
|
178
|
+
...cleanObject(environmentOverrides.parameters),
|
|
179
|
+
...cleanObject(cleanObject(environment).parameters),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const sunRig = sun !== false
|
|
183
|
+
? createEnvironmentSunRig({
|
|
184
|
+
environmentBox,
|
|
185
|
+
scene,
|
|
186
|
+
...(Array.isArray(skySunDirection) ? {
|
|
187
|
+
sourceRatios: {
|
|
188
|
+
x: skySunDirection[0],
|
|
189
|
+
y: Math.max(skySunDirection[1], 0.2),
|
|
190
|
+
z: skySunDirection[2],
|
|
191
|
+
},
|
|
192
|
+
} : {}),
|
|
193
|
+
})
|
|
194
|
+
: null;
|
|
195
|
+
|
|
196
|
+
// Shadows are ON by default — a character that casts nothing on the grass
|
|
197
|
+
// is the fastest way for a stylized world to read fake. The renderer's
|
|
198
|
+
// shadow maps are enabled, everything under the terrain root casts and
|
|
199
|
+
// receives, the follow target casts, and — critically — the sun's shadow
|
|
200
|
+
// camera is shrunk to a window (default 140 m) that FOLLOWS the target:
|
|
201
|
+
// one map stretched across a whole open world has no texel density left
|
|
202
|
+
// for a character-sized shadow. Pass `shadows: false` to opt out, or
|
|
203
|
+
// `shadows: { area }` to resize the window.
|
|
204
|
+
const shadowOptions = cleanObject(shadows);
|
|
205
|
+
const shadowArea = shadows === false ? 0 : Number(shadowOptions.area) || 140;
|
|
206
|
+
let sunShadowOffset = null;
|
|
207
|
+
if (shadowArea > 0) {
|
|
208
|
+
if (renderer.shadowMap) renderer.shadowMap.enabled = true;
|
|
209
|
+
terrainRoot.traverse((object) => {
|
|
210
|
+
if (object.isMesh) {
|
|
211
|
+
object.castShadow = true;
|
|
212
|
+
object.receiveShadow = true;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
followTarget?.traverse?.((object) => {
|
|
216
|
+
if (object.isMesh) object.castShadow = true;
|
|
217
|
+
});
|
|
218
|
+
if (sunRig?.light) {
|
|
219
|
+
const light = sunRig.light;
|
|
220
|
+
const shadowCamera = light.shadow.camera;
|
|
221
|
+
shadowCamera.left = -shadowArea;
|
|
222
|
+
shadowCamera.right = shadowArea;
|
|
223
|
+
shadowCamera.top = shadowArea;
|
|
224
|
+
shadowCamera.bottom = -shadowArea;
|
|
225
|
+
shadowCamera.near = 0.5;
|
|
226
|
+
shadowCamera.far = shadowArea * 6 + 400;
|
|
227
|
+
shadowCamera.updateProjectionMatrix();
|
|
228
|
+
// Tight window + high sun → self-shadow acne on flat ground without a
|
|
229
|
+
// healthy normal bias.
|
|
230
|
+
light.shadow.normalBias = Math.max(light.shadow.normalBias ?? 0, 0.5);
|
|
231
|
+
// Derive the shadow sun's direction from the sky's actual sun vector.
|
|
232
|
+
// (Deriving it from the rig light's box-relative position warps the
|
|
233
|
+
// elevation by the terrain box's aspect ratio — a 2 km × 200 m box
|
|
234
|
+
// squashes a 2 PM sun nearly horizontal, and shadows land tens of
|
|
235
|
+
// meters away from their casters.)
|
|
236
|
+
const direction = Array.isArray(skySunDirection)
|
|
237
|
+
? new THREE.Vector3(
|
|
238
|
+
skySunDirection[0],
|
|
239
|
+
Math.max(skySunDirection[1], 0.15),
|
|
240
|
+
skySunDirection[2],
|
|
241
|
+
)
|
|
242
|
+
: light.position.clone().sub(environmentBox.getCenter(new THREE.Vector3()));
|
|
243
|
+
sunShadowOffset = direction.normalize().multiplyScalar(shadowArea * 3 + 120);
|
|
244
|
+
scene.add(light.target);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// On the TSL/WebGPU backends three's shadow system is bypassed by design;
|
|
248
|
+
// this pass renders the sun's shadow map each frame and publishes it to
|
|
249
|
+
// the environment and character shaders. Without it there are NO shadows,
|
|
250
|
+
// regardless of castShadow flags — which is why the composed world owns it.
|
|
251
|
+
const sunShadowPass = shadowArea > 0 && sunRig?.light
|
|
252
|
+
? createEnvironmentSunShadowPass({ renderer, scene })
|
|
253
|
+
: null;
|
|
254
|
+
const shadowInterval = Math.max(Math.trunc(shadowOptions.interval ?? 2), 1);
|
|
255
|
+
let shadowFrame = 0;
|
|
256
|
+
|
|
257
|
+
const followSunShadow = () => {
|
|
258
|
+
if (!sunShadowOffset || !sunRig?.light) return;
|
|
259
|
+
const focus = followTarget?.position ?? camera?.position;
|
|
260
|
+
if (!focus) return;
|
|
261
|
+
sunRig.light.position.copy(focus).add(sunShadowOffset);
|
|
262
|
+
sunRig.light.target.position.copy(focus);
|
|
263
|
+
sunRig.light.target.updateMatrixWorld();
|
|
264
|
+
};
|
|
265
|
+
followSunShadow();
|
|
266
|
+
|
|
267
|
+
// Sky dome — the water's reflection fallback, so it comes before water.
|
|
268
|
+
const sky = new StylizedSky({
|
|
269
|
+
preset: worldPreset.sky?.preset,
|
|
270
|
+
...cleanObject(worldPreset.sky?.settings),
|
|
271
|
+
...cleanObject(cleanObject(skyOptions).settings),
|
|
272
|
+
});
|
|
273
|
+
scene.add(sky);
|
|
274
|
+
|
|
275
|
+
// Water with the terrain as its bed: waves shoal and break on real shores.
|
|
276
|
+
const waterLevel = Number(cleanObject(water).level) || 0;
|
|
277
|
+
let waterSurface = null;
|
|
278
|
+
if (water !== false) {
|
|
279
|
+
const waterOptions = cleanObject(water);
|
|
280
|
+
waterSurface = new WaterSurface({
|
|
281
|
+
preset: worldPreset.water?.preset,
|
|
282
|
+
width: Number(waterOptions.width) || width,
|
|
283
|
+
depth: Number(waterOptions.depth) || depth,
|
|
284
|
+
...(heightAt ? { bedHeight: heightAt } : {}),
|
|
285
|
+
...cleanObject(waterOptions.settings),
|
|
286
|
+
});
|
|
287
|
+
waterSurface.position.y = waterLevel;
|
|
288
|
+
// Match the environment's height fog so far-shore water hazes exactly
|
|
289
|
+
// like the terrain behind it (mismatch reads as water slicing into the
|
|
290
|
+
// mountains).
|
|
291
|
+
waterSurface.setDistanceFog?.({
|
|
292
|
+
color: heightFogParams.heightFogColor ?? [0.66, 0.8, 0.94],
|
|
293
|
+
density: Number(heightFogParams.heightFogDensity) || 0.0016,
|
|
294
|
+
});
|
|
295
|
+
scene.add(waterSurface);
|
|
296
|
+
if (followTarget) {
|
|
297
|
+
waterSurface.addInteractor(followTarget, { radius: 0.35 });
|
|
298
|
+
waterSurface.setFollowTarget(followTarget);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Placement masks: no vegetation on cliffs or under water.
|
|
303
|
+
const masks = heightAt
|
|
304
|
+
? {
|
|
305
|
+
slope: createSlopeMask({ heightAt, maxSlope: 0.55 }),
|
|
306
|
+
water: createWaterMask({ heightAt, margin: 0.4, waterLevel }),
|
|
307
|
+
}
|
|
308
|
+
: null;
|
|
309
|
+
const vegetationMask = masks ? combineMasks(masks.slope, masks.water) : null;
|
|
310
|
+
const defaultCenter = followTarget?.position ?? { x: 0, z: 0 };
|
|
311
|
+
// Per-system custom masks (e.g. a createNoisePatchMask forest-cluster
|
|
312
|
+
// mask) AND with the built-in slope/water masks.
|
|
313
|
+
const withWorldMask = (custom) => {
|
|
314
|
+
if (typeof custom !== 'function') return vegetationMask;
|
|
315
|
+
return vegetationMask ? combineMasks(vegetationMask, custom) : custom;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Trees: LOD forest. Far placements render as baked instanced impostors
|
|
319
|
+
// (a couple of draw calls per variant); a budgeted pool of live detailed
|
|
320
|
+
// trees swaps in around the camera. See StylizedForest.
|
|
321
|
+
let forest = null;
|
|
322
|
+
if (trees !== false) {
|
|
323
|
+
const treeOptions = cleanObject(trees);
|
|
324
|
+
const placements = scatterForest({
|
|
325
|
+
...cleanObject(worldPreset.trees?.scatter),
|
|
326
|
+
center: treeOptions.center ?? defaultCenter,
|
|
327
|
+
radius: Math.min(cleanObject(treeOptions.scatter).radius
|
|
328
|
+
?? cleanObject(worldPreset.trees?.scatter).radius ?? halfExtent, halfExtent),
|
|
329
|
+
...cleanObject(treeOptions.scatter),
|
|
330
|
+
heightAt,
|
|
331
|
+
mask: withWorldMask(treeOptions.mask),
|
|
332
|
+
});
|
|
333
|
+
forest = new StylizedForest({
|
|
334
|
+
canopyColors: treeOptions.canopyColors ?? worldPreset.trees?.canopyColors ?? null,
|
|
335
|
+
placements,
|
|
336
|
+
preset: worldPreset.trees?.preset,
|
|
337
|
+
// Billboard far-LOD: without the renderer the forest falls back to
|
|
338
|
+
// merged-geometry impostors (~12k verts/tree) that get redrawn by the
|
|
339
|
+
// main, water, and shadow passes — the difference between 20 and 60 fps.
|
|
340
|
+
renderer,
|
|
341
|
+
settings: {
|
|
342
|
+
...cleanObject(worldPreset.trees?.settings),
|
|
343
|
+
...cleanObject(treeOptions.settings),
|
|
344
|
+
},
|
|
345
|
+
...cleanObject(treeOptions.lod),
|
|
346
|
+
});
|
|
347
|
+
// Same height-fog layer the terrain and water get: without it, far
|
|
348
|
+
// impostor canopies only receive the linear scene.fog and float on the
|
|
349
|
+
// fogged mountains as saturated green dots.
|
|
350
|
+
forest.setDistanceFog({
|
|
351
|
+
color: heightFogParams.heightFogColor ?? [0.66, 0.8, 0.94],
|
|
352
|
+
density: Number(heightFogParams.heightFogDensity) || 0.0016,
|
|
353
|
+
falloff: Number(heightFogParams.heightFogFalloff) || 400,
|
|
354
|
+
floorY: environmentBox.min.y,
|
|
355
|
+
});
|
|
356
|
+
scene.add(forest);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Grass: a density-based window that follows the target. Placements are
|
|
360
|
+
// construction-only, so when the target strays past half the radius the
|
|
361
|
+
// field is rebuilt at the new center and swapped; a distance fade hides
|
|
362
|
+
// the window edge. Far grass simply doesn't exist — same as the anime
|
|
363
|
+
// games this emulates.
|
|
364
|
+
let grassField = null;
|
|
365
|
+
const grassState = { center: null, options: cleanObject(grass), radius: 0 };
|
|
366
|
+
const buildGrassAt = (center) => {
|
|
367
|
+
const grassOptions = grassState.options;
|
|
368
|
+
const scatterSpec = {
|
|
369
|
+
...cleanObject(worldPreset.grass?.scatter),
|
|
370
|
+
...cleanObject(grassOptions.scatter),
|
|
371
|
+
};
|
|
372
|
+
grassState.radius = Number(scatterSpec.radius) || 45;
|
|
373
|
+
const placements = scatterGrassAround({
|
|
374
|
+
...scatterSpec,
|
|
375
|
+
center,
|
|
376
|
+
heightAt,
|
|
377
|
+
mask: withWorldMask(grassOptions.mask),
|
|
378
|
+
});
|
|
379
|
+
if (placements.length === 0) return null;
|
|
380
|
+
const field = new StylizedGrassField({
|
|
381
|
+
preset: worldPreset.grass?.preset,
|
|
382
|
+
...cleanObject(worldPreset.grass?.settings),
|
|
383
|
+
...cleanObject(grassOptions.settings),
|
|
384
|
+
placements,
|
|
385
|
+
});
|
|
386
|
+
field.setDistanceFade({ end: grassState.radius * 0.98, start: grassState.radius * 0.62 });
|
|
387
|
+
// 300k blades re-rendered into the water reflection, refraction grab,
|
|
388
|
+
// and depth passes is pure cost — grass sits above the waterline, so
|
|
389
|
+
// its contribution to all three is invisible at gameplay angles.
|
|
390
|
+
// (waterExclude hides from every water scene pass.)
|
|
391
|
+
field.userData.waterExclude = true;
|
|
392
|
+
scene.add(field);
|
|
393
|
+
if (followTarget) field.setPushTarget(followTarget);
|
|
394
|
+
grassState.center = { x: Number(center.x) || 0, z: Number(center.z) || 0 };
|
|
395
|
+
return field;
|
|
396
|
+
};
|
|
397
|
+
if (grass !== false) {
|
|
398
|
+
grassField = buildGrassAt(cleanObject(grass).center ?? defaultCenter);
|
|
399
|
+
}
|
|
400
|
+
const refreshGrassWindow = () => {
|
|
401
|
+
if (grass === false || !followTarget || !grassState.center) return;
|
|
402
|
+
const dx = followTarget.position.x - grassState.center.x;
|
|
403
|
+
const dz = followTarget.position.z - grassState.center.z;
|
|
404
|
+
if (dx * dx + dz * dz < (grassState.radius * 0.5) ** 2) return;
|
|
405
|
+
const previous = grassField;
|
|
406
|
+
grassField = buildGrassAt(followTarget.position) ?? previous;
|
|
407
|
+
if (previous && grassField !== previous) {
|
|
408
|
+
scene.remove(previous);
|
|
409
|
+
previous.dispose();
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
let flowerField = null;
|
|
414
|
+
if (flowers) {
|
|
415
|
+
const flowerOptions = cleanObject(flowers);
|
|
416
|
+
const placements = scatterGrassAround({
|
|
417
|
+
center: flowerOptions.center ?? defaultCenter,
|
|
418
|
+
density: 0.4,
|
|
419
|
+
radius: 30,
|
|
420
|
+
seed: 11,
|
|
421
|
+
...cleanObject(flowerOptions.scatter),
|
|
422
|
+
heightAt,
|
|
423
|
+
mask: withWorldMask(flowerOptions.mask),
|
|
424
|
+
});
|
|
425
|
+
if (placements.length > 0) {
|
|
426
|
+
flowerField = new StylizedFlowerField({
|
|
427
|
+
...cleanObject(flowerOptions.settings),
|
|
428
|
+
placements,
|
|
429
|
+
});
|
|
430
|
+
scene.add(flowerField);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Walkability: circle blockers on a spatial hash, tree trunks
|
|
435
|
+
// pre-registered, so characters can't walk through the world ToonLab just
|
|
436
|
+
// built. Hosts add their own blockers (rocks, props) via
|
|
437
|
+
// `world.collision.addCircles(...)` and call
|
|
438
|
+
// `world.collision.resolve(character.position, radius)` per frame.
|
|
439
|
+
const collision = createWorldCollision({ heightAt });
|
|
440
|
+
if (forest) {
|
|
441
|
+
const trunkScale = [
|
|
442
|
+
cleanObject(cleanObject(trees).settings).size,
|
|
443
|
+
cleanObject(worldPreset.trees?.settings).size,
|
|
444
|
+
3.2,
|
|
445
|
+
].map(Number).find(Number.isFinite);
|
|
446
|
+
const trunkRadius = Math.max(0.13 * trunkScale, 0.2);
|
|
447
|
+
collision.addCircles(forest.placements.map((p) => ({ radius: trunkRadius, x: p.x, z: p.z })));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// One cloud-shadow field across terrain shader, water, grass, and canopies.
|
|
451
|
+
const applyCloudShadow = (field) => {
|
|
452
|
+
setEnvironmentCloudShadow(field);
|
|
453
|
+
waterSurface?.setCloudShadow(field);
|
|
454
|
+
grassField?.setCloudShadow(field);
|
|
455
|
+
forest?.setCloudShadow(field);
|
|
456
|
+
};
|
|
457
|
+
if (cloudShadows !== false) applyCloudShadow(cleanObject(cloudShadows));
|
|
458
|
+
|
|
459
|
+
let disposed = false;
|
|
460
|
+
return {
|
|
461
|
+
classification,
|
|
462
|
+
collision,
|
|
463
|
+
dispose() {
|
|
464
|
+
if (disposed) return;
|
|
465
|
+
disposed = true;
|
|
466
|
+
sunShadowPass?.dispose();
|
|
467
|
+
forest?.dispose();
|
|
468
|
+
for (const object of [sky, waterSurface, forest, grassField, flowerField, sunRig?.group ?? sunRig]) {
|
|
469
|
+
if (object?.parent) object.parent.remove(object);
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
flowerField,
|
|
473
|
+
fog: sceneFog,
|
|
474
|
+
forest,
|
|
475
|
+
get grass() { return grassField; },
|
|
476
|
+
masks,
|
|
477
|
+
setCloudShadow: applyCloudShadow,
|
|
478
|
+
sky,
|
|
479
|
+
sunRig,
|
|
480
|
+
/** Call once per frame before rendering. */
|
|
481
|
+
update(delta = 0.016) {
|
|
482
|
+
if (disposed) return;
|
|
483
|
+
advanceEnvironmentShaderTime(delta);
|
|
484
|
+
followSunShadow();
|
|
485
|
+
// Rendering the shadow map is a full scene pass; alternate frames are
|
|
486
|
+
// imperceptible for a sun that barely moves (shadows.interval: 1 to
|
|
487
|
+
// force every frame).
|
|
488
|
+
shadowFrame += 1;
|
|
489
|
+
if (sunShadowPass && shadowFrame % shadowInterval === 0) sunShadowPass.update();
|
|
490
|
+
refreshGrassWindow();
|
|
491
|
+
waterSurface?.update(renderer, scene, camera, delta);
|
|
492
|
+
sky.update(delta, camera);
|
|
493
|
+
grassField?.update(delta);
|
|
494
|
+
flowerField?.update(delta);
|
|
495
|
+
forest?.update(delta, camera);
|
|
496
|
+
},
|
|
497
|
+
water: waterSurface,
|
|
498
|
+
worldPreset,
|
|
499
|
+
};
|
|
500
|
+
}
|