@call-me-sensei/toonlab 0.2.0 → 0.3.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 +127 -4
- package/ATTRIBUTION.md +41 -3
- package/README.md +234 -34
- package/docs/characters.md +144 -0
- package/docs/debug-panel.md +126 -0
- package/docs/docs.css +589 -0
- package/docs/environment.md +186 -0
- package/docs/getting-started.md +180 -0
- package/docs/index.html +14 -0
- package/docs/lab-architecture.md +100 -0
- package/docs/lighting.md +825 -0
- package/docs/main.jsx +739 -0
- package/docs/mcp.md +97 -0
- package/docs/post-processing.md +98 -0
- package/docs/settings-reference.md +1878 -0
- package/docs/shader-constants.md +77 -0
- package/docs/sky.md +182 -0
- package/docs/style-labs.md +309 -0
- package/docs/texture-lab.md +135 -0
- package/docs/toon-shading.md +183 -0
- package/docs/tsl-conventions.md +167 -0
- package/docs/vegetation-sky.md +275 -0
- package/docs/water.md +430 -0
- package/docs/weather.md +200 -0
- package/docs/world-scale.md +111 -0
- package/mcp/server.mjs +610 -0
- package/mcp/style-lab-tools.mjs +371 -0
- package/mcp/vite-plugin.mjs +174 -0
- package/mcp/workspace.mjs +397 -0
- package/package.json +64 -5
- package/src/ambientfx/INTEGRATION.md +164 -0
- package/src/ambientfx/ambientFxPresets.js +83 -0
- package/src/ambientfx/ambientFxSettings.js +368 -0
- package/src/ambientfx/emitters.js +169 -0
- package/src/ambientfx/index.js +5 -0
- package/src/ambientfx/particleBackbone.js +493 -0
- package/src/ambientfx/stylizedAmbientFx.js +450 -0
- package/src/assetlib/ambientcg.js +117 -0
- package/src/assetlib/assetRef.js +91 -0
- package/src/assetlib/importedEntry.js +40 -0
- package/src/assetlib/index.js +23 -0
- package/src/assetlib/kaykit.js +192 -0
- package/src/assetlib/kaykitStaticIndex.js +700 -0
- package/src/assetlib/loadImported.js +143 -0
- package/src/assetlib/opensource3d.js +113 -0
- package/src/assetlib/polyhaven.js +182 -0
- package/src/assetlib/polypizza.js +115 -0
- package/src/assetlib/smithsonian.js +214 -0
- package/src/assetlib/sources.js +279 -0
- package/src/assetlib/zip.js +58 -0
- package/src/biome/biomeGenerator.js +385 -0
- package/src/biome/biomeRuntime.js +299 -0
- package/src/biome/index.js +2 -0
- package/src/buildinggen/buildingAsset.js +44 -0
- package/src/buildinggen/buildingGrammar.js +311 -0
- package/src/buildinggen/buildingMesh.js +451 -0
- package/src/buildinggen/buildingPresets.js +46 -0
- package/src/buildinggen/buildingRecipe.js +100 -0
- package/src/buildinggen/buildingSettings.js +238 -0
- package/src/buildinggen/index.js +6 -0
- package/src/camera/cameraDirector.js +157 -0
- package/src/camera/cameraGenerator.js +367 -0
- package/src/camera/cameraRig.js +570 -0
- package/src/camera/cameraSettings.js +236 -0
- package/src/camera/index.js +7 -0
- package/src/catalog/builtinEntries.js +245 -0
- package/src/catalog/catalog.js +210 -0
- package/src/catalog/index.js +3 -0
- package/src/catalog/manifest.js +84 -0
- package/src/core/generation.js +529 -0
- package/src/environment/environmentRigs.js +21 -1
- package/src/environment/environmentSettings.js +4 -0
- package/src/environment/environmentSunShadowPass.js +8 -0
- package/src/fauna/INTEGRATION.md +174 -0
- package/src/fauna/boids.js +861 -0
- package/src/fauna/faunaBodies.js +492 -0
- package/src/fauna/faunaPresets.js +52 -0
- package/src/fauna/faunaSettings.js +525 -0
- package/src/fauna/index.js +5 -0
- package/src/fauna/stylizedFauna.js +395 -0
- package/src/game-feel/gameFeelGenerator.js +402 -0
- package/src/game-feel/gameFeelRuntime.js +549 -0
- package/src/game-feel/index.js +2 -0
- package/src/index.js +17 -6
- package/src/lighting/colorIntensity.js +177 -0
- package/src/lighting/index.js +161 -0
- package/src/lighting/lightDescriptors.js +249 -0
- package/src/lighting/lightingCapabilities.js +79 -0
- package/src/lighting/lightingDocuments.js +247 -0
- package/src/lighting/lightingFixtures.js +446 -0
- package/src/lighting/lightingGenerator.js +449 -0
- package/src/lighting/lightingPresets.js +319 -0
- package/src/lighting/lightingRuntime.js +723 -0
- package/src/lighting/lightingStyle.js +386 -0
- package/src/lighting/lightingSystem.js +774 -0
- package/src/lighting/unrealExport.js +186 -0
- package/src/lighting/utils.js +87 -0
- package/src/motion/index.js +5 -0
- package/src/motion/motionClip.js +441 -0
- package/src/motion/motionController.js +628 -0
- package/src/motion/motionDocuments.js +225 -0
- package/src/motion/motionGraph.js +307 -0
- package/src/motion/motionSettings.js +222 -0
- package/src/pathgen/index.js +7 -0
- package/src/pathgen/pathBridge.js +232 -0
- package/src/pathgen/pathPresets.js +35 -0
- package/src/pathgen/pathRibbon.js +410 -0
- package/src/pathgen/pathRouter.js +380 -0
- package/src/pathgen/pathSettings.js +335 -0
- package/src/pathgen/pathTextures.js +123 -0
- package/src/pathgen/stylizedPaths.js +453 -0
- package/src/post/index.js +1 -0
- package/src/post/postGenerator.js +177 -0
- package/src/post/postProcessing.js +41 -0
- package/src/propgen/generatorsWave1.js +379 -0
- package/src/propgen/generatorsWave2.js +462 -0
- package/src/propgen/index.js +5 -0
- package/src/propgen/propAsset.js +323 -0
- package/src/propgen/propParts.js +170 -0
- package/src/propgen/propPlacement.js +459 -0
- package/src/propgen/propPresets.js +82 -0
- package/src/propgen/propSettings.js +395 -0
- package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
- package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
- package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
- package/src/shaders-tsl/chunks/water-waves.js +90 -11
- package/src/shaders-tsl/environment.js +15 -1
- package/src/shaders-tsl/flower.js +279 -30
- package/src/shaders-tsl/grass.js +60 -33
- package/src/shaders-tsl/sky.js +125 -31
- package/src/shaders-tsl/tree-leaf.js +61 -25
- package/src/shaders-tsl/water-breaker.js +7 -4
- package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
- package/src/shaders-tsl/water.js +439 -49
- package/src/shaders-tsl/woody-surface.js +154 -0
- package/src/sky/sceneOverrideLayers.js +10 -0
- package/src/sky/skyQuality.js +26 -0
- package/src/sky/stylizedSky.js +753 -45
- package/src/soundscape/index.js +4 -0
- package/src/soundscape/soundscapeGenerator.js +179 -0
- package/src/soundscape/soundscapeRuntime.js +806 -0
- package/src/soundscape/soundscapeSettings.js +292 -0
- package/src/styles/index.js +13 -0
- package/src/styles/styleBundle.js +325 -0
- package/src/stylizedTerrain.js +32 -2
- package/src/stylizedWorld.js +423 -20
- package/src/texgen/evaluateTexture.js +675 -0
- package/src/texgen/index.js +60 -0
- package/src/texgen/noise2.js +210 -0
- package/src/texgen/textureAi.js +436 -0
- package/src/texgen/textureGenerators.js +516 -0
- package/src/texgen/texturePresets.js +490 -0
- package/src/texgen/textureSettings.js +342 -0
- package/src/texgen/textureThree.js +59 -0
- package/src/vegetation/flowerSpecies.js +15 -3
- package/src/vegetation/grassPalettes.js +153 -0
- package/src/vegetation/index.js +6 -0
- package/src/vegetation/stylizedBush.js +2 -0
- package/src/vegetation/stylizedFlower.js +82 -0
- package/src/vegetation/stylizedFlowers.js +48 -7
- package/src/vegetation/stylizedForest.js +29 -1
- package/src/vegetation/stylizedGrass.js +291 -56
- package/src/vegetation/stylizedTree.js +38 -2
- package/src/vegetation/stylizedTreeFoliage.js +2 -1
- package/src/vegetation/vegetationShaders.js +1110 -0
- package/src/vfxgen/INTEGRATION.md +145 -0
- package/src/vfxgen/core/burstBackbone.js +380 -0
- package/src/vfxgen/core/projectileCore.js +92 -0
- package/src/vfxgen/core/spriteShapes.js +98 -0
- package/src/vfxgen/core/trailRibbon.js +272 -0
- package/src/vfxgen/core/vfxRandom.js +29 -0
- package/src/vfxgen/effects/emitHelpers.js +37 -0
- package/src/vfxgen/effects/magicEffects.js +162 -0
- package/src/vfxgen/effects/movementEffects.js +87 -0
- package/src/vfxgen/effects/weaponEffects.js +118 -0
- package/src/vfxgen/index.js +18 -0
- package/src/vfxgen/moves/moveController.js +146 -0
- package/src/vfxgen/moves/moveLibrary.js +335 -0
- package/src/vfxgen/vfxPresets.js +98 -0
- package/src/vfxgen/vfxSettings.js +384 -0
- package/src/vfxgen/vfxSystem.js +449 -0
- package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
- package/src/villagegen/index.js +4 -0
- package/src/villagegen/stylizedVillage.js +490 -0
- package/src/villagegen/villageArchetypes.js +160 -0
- package/src/villagegen/villageNames.js +40 -0
- package/src/villagegen/villageSites.js +105 -0
- package/src/water/sceneOverrideLayers.js +23 -0
- package/src/water/water.js +5 -0
- package/src/water/waterBreakerSystem.js +15 -1
- package/src/water/waterCurrentField.js +447 -0
- package/src/water/waterMaterial.js +12 -0
- package/src/water/waterNearshorePhase.js +320 -0
- package/src/water/waterScenePasses.js +83 -30
- package/src/water/waterSettings.js +325 -13
- package/src/water/waterShoreMaterial.js +322 -0
- package/src/water/waterShoreStateField.js +605 -0
- package/src/water/waterSurface.js +797 -28
- package/src/weather/index.js +6 -0
- package/src/weather/weatherPrecipitation.js +221 -0
- package/src/weather/weatherPresets.js +258 -0
- package/src/weather/weatherSettings.js +269 -0
- package/src/weather/weatherSystem.js +871 -0
- package/src/worldMinimap.js +62 -0
- package/src/worldPresets.js +4 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Named ambient-fx presets: 'default' is the baseline; 'call_me_sensei' is
|
|
2
|
+
// the studio-managed signature look, curated and updated over releases.
|
|
3
|
+
// Community presets register alongside them via registerAmbientFxPreset().
|
|
4
|
+
// Preset settings are nested partials over DEFAULT_AMBIENTFX_SETTINGS.
|
|
5
|
+
|
|
6
|
+
function cleanObject(value) {
|
|
7
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ambientFxPresetRegistry = new Map([
|
|
11
|
+
['default', Object.freeze({
|
|
12
|
+
description: 'Baseline ambient atmosphere.',
|
|
13
|
+
label: 'Default',
|
|
14
|
+
settings: Object.freeze({}),
|
|
15
|
+
})],
|
|
16
|
+
['call_me_sensei', Object.freeze({
|
|
17
|
+
description: 'Studio-managed signature atmosphere, curated by Call Me Sensei and updated over releases. Denser blossom fall, warm slow fireflies, and a touch more dawn mist.',
|
|
18
|
+
label: 'Call Me Sensei',
|
|
19
|
+
settings: Object.freeze({
|
|
20
|
+
petals: Object.freeze({
|
|
21
|
+
canopyDensity: 6.5,
|
|
22
|
+
colorA: Object.freeze([1.0, 0.5, 0.7]),
|
|
23
|
+
colorB: Object.freeze([1.0, 0.78, 0.87]),
|
|
24
|
+
density: 0.04,
|
|
25
|
+
flutter: 1.25,
|
|
26
|
+
}),
|
|
27
|
+
leaves: Object.freeze({
|
|
28
|
+
colorA: Object.freeze([0.95, 0.66, 0.18]),
|
|
29
|
+
colorB: Object.freeze([0.82, 0.42, 0.12]),
|
|
30
|
+
windResponse: 1.5,
|
|
31
|
+
}),
|
|
32
|
+
fireflies: Object.freeze({
|
|
33
|
+
blinkSpeed: 0.85,
|
|
34
|
+
color: Object.freeze([1.0, 0.82, 0.38]),
|
|
35
|
+
density: 0.06,
|
|
36
|
+
intensity: 1.2,
|
|
37
|
+
}),
|
|
38
|
+
pollen: Object.freeze({
|
|
39
|
+
backlitStrength: 1.3,
|
|
40
|
+
density: 0.075,
|
|
41
|
+
}),
|
|
42
|
+
mist: Object.freeze({
|
|
43
|
+
density: 0.0055,
|
|
44
|
+
opacity: 0.4,
|
|
45
|
+
}),
|
|
46
|
+
}),
|
|
47
|
+
})],
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Registers a named ambient-fx preset so it resolves in
|
|
52
|
+
* `createAmbientFx({ preset })` exactly like the built-ins. Accepts
|
|
53
|
+
* `{ label?, description?, settings? }` or flat (nested) settings.
|
|
54
|
+
*/
|
|
55
|
+
export function registerAmbientFxPreset(name, preset = {}, { overwrite = false } = {}) {
|
|
56
|
+
const id = String(name ?? '').trim();
|
|
57
|
+
if (!id) throw new Error('Ambient-fx preset name is required.');
|
|
58
|
+
if (!overwrite && ambientFxPresetRegistry.has(id)) {
|
|
59
|
+
throw new Error(`Ambient-fx preset "${id}" already exists.`);
|
|
60
|
+
}
|
|
61
|
+
const { label, description, settings, ...flat } = cleanObject(preset);
|
|
62
|
+
const entry = Object.freeze({
|
|
63
|
+
description: typeof description === 'string' ? description : '',
|
|
64
|
+
label: typeof label === 'string' && label ? label : id,
|
|
65
|
+
settings: Object.freeze({ ...cleanObject(settings ?? flat) }),
|
|
66
|
+
});
|
|
67
|
+
ambientFxPresetRegistry.set(id, entry);
|
|
68
|
+
return { description: entry.description, id, label: entry.label };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Lists registered presets as `{ id, label, description }` (for HUDs). */
|
|
72
|
+
export function getAmbientFxPresetOptions() {
|
|
73
|
+
return Array.from(ambientFxPresetRegistry.entries()).map(([id, preset]) => ({
|
|
74
|
+
description: preset.description,
|
|
75
|
+
id,
|
|
76
|
+
label: preset.label,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Nested settings partial for a preset name; `{}` for unknown names. */
|
|
81
|
+
export function resolveAmbientFxPreset(name) {
|
|
82
|
+
return ambientFxPresetRegistry.get(name)?.settings ?? {};
|
|
83
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// Canonical settings and field metadata for the ambient-VFX cluster
|
|
2
|
+
// (petals, falling leaves, fireflies, pollen motes, ground mist). Follows the
|
|
3
|
+
// library-wide convention: DEFAULT_AMBIENTFX_SETTINGS holds grouped values,
|
|
4
|
+
// AMBIENTFX_SETTING_GROUPS + AMBIENTFX_SETTING_FIELD_SCHEMA drive the debug
|
|
5
|
+
// panel and the generated settings reference. All five effects are presets
|
|
6
|
+
// over one shared particle backbone (see particleBackbone.js), so the shared
|
|
7
|
+
// group carries the wind/window state every effect reads.
|
|
8
|
+
//
|
|
9
|
+
// The wind trio (windDirection/windSpeed/windStrength) deliberately matches
|
|
10
|
+
// DEFAULT_GRASS_SETTINGS so a host can pipe the same values to grass, trees,
|
|
11
|
+
// and ambient fx and everything blows the same way.
|
|
12
|
+
|
|
13
|
+
/** Effect ids in backbone kind order — the per-instance `kind` attribute. */
|
|
14
|
+
export const AMBIENTFX_EFFECT_IDS = Object.freeze(['petals', 'leaves', 'fireflies', 'pollen', 'mist']);
|
|
15
|
+
|
|
16
|
+
/** Named time gates. Hours follow environmentTimeOfDay (sun up ≈ 6–18). */
|
|
17
|
+
export const AMBIENTFX_TIME_GATES = Object.freeze(['day', 'night', 'duskNight', 'dawnDusk', 'any']);
|
|
18
|
+
|
|
19
|
+
function ramp(hour, start, end) {
|
|
20
|
+
if (end <= start) return 1;
|
|
21
|
+
return Math.min(1, Math.max(0, (hour - start) / (end - start)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pure 0..1 weight of a named time gate at `hour` (0–24, wraps). Keyed to the
|
|
26
|
+
* environmentTimeOfDay day structure so fireflies ramp with the existing
|
|
27
|
+
* clock instead of inventing a second one. Deliberately reaches EXACT 0/1 in
|
|
28
|
+
* the plateaus (fireflies are hard-off at noon, pollen hard-off at night) so
|
|
29
|
+
* gated effects cost nothing outside their hours.
|
|
30
|
+
*/
|
|
31
|
+
export function timeGateWeight(gate, hour) {
|
|
32
|
+
const h = ((Number(hour) % 24) + 24) % 24;
|
|
33
|
+
const day = ramp(h, 6, 8) * (1 - ramp(h, 17, 19));
|
|
34
|
+
switch (gate) {
|
|
35
|
+
case 'day': return day;
|
|
36
|
+
case 'night': return h < 12 ? 1 - ramp(h, 4.5, 6.5) : ramp(h, 18.5, 20.5);
|
|
37
|
+
case 'duskNight': return h > 12 ? ramp(h, 17.5, 19.5) : 1 - ramp(h, 4.5, 6.5);
|
|
38
|
+
// Two soft shoulders around sunrise/sunset; mist burns off by mid-morning.
|
|
39
|
+
case 'dawnDusk': return Math.max(
|
|
40
|
+
ramp(h, 3.5, 5.5) * (1 - ramp(h, 7.5, 9.5)),
|
|
41
|
+
ramp(h, 16.5, 18.5) * (1 - ramp(h, 21, 23)),
|
|
42
|
+
);
|
|
43
|
+
default: return 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Default ambient-fx settings. Densities are particles per m³ of each
|
|
49
|
+
* effect's own emission domain (window disk × its height band, or bloom
|
|
50
|
+
* volumes for canopy-bound effects); at the default 45 m window they total
|
|
51
|
+
* ≈ 4k live particles, so 3× density stays well inside the 20k budget.
|
|
52
|
+
*/
|
|
53
|
+
export const DEFAULT_AMBIENTFX_SETTINGS = Object.freeze({
|
|
54
|
+
shared: Object.freeze({
|
|
55
|
+
windDirection: Object.freeze([1, 0.3]),
|
|
56
|
+
windSpeed: 1.0,
|
|
57
|
+
windStrength: 0.16,
|
|
58
|
+
sunDirection: Object.freeze([0.35, 0.72, 0.42]),
|
|
59
|
+
windowRadius: 45,
|
|
60
|
+
maxParticles: 20000,
|
|
61
|
+
}),
|
|
62
|
+
petals: Object.freeze({
|
|
63
|
+
enabled: true,
|
|
64
|
+
density: 0.03,
|
|
65
|
+
canopyDensity: 4.5,
|
|
66
|
+
sizeRange: Object.freeze([0.06, 0.11]),
|
|
67
|
+
colorA: Object.freeze([1.0, 0.52, 0.68]),
|
|
68
|
+
colorB: Object.freeze([1.0, 0.75, 0.84]),
|
|
69
|
+
emitHeight: Object.freeze([2, 9]),
|
|
70
|
+
flutter: 1.0,
|
|
71
|
+
windResponse: 1.0,
|
|
72
|
+
gate: 'day',
|
|
73
|
+
}),
|
|
74
|
+
leaves: Object.freeze({
|
|
75
|
+
enabled: true,
|
|
76
|
+
density: 0.022,
|
|
77
|
+
canopyDensity: 3.2,
|
|
78
|
+
sizeRange: Object.freeze([0.09, 0.16]),
|
|
79
|
+
colorA: Object.freeze([0.93, 0.64, 0.2]),
|
|
80
|
+
colorB: Object.freeze([0.78, 0.4, 0.13]),
|
|
81
|
+
emitHeight: Object.freeze([2, 10]),
|
|
82
|
+
tumble: 1.0,
|
|
83
|
+
windResponse: 1.35,
|
|
84
|
+
gate: 'any',
|
|
85
|
+
}),
|
|
86
|
+
fireflies: Object.freeze({
|
|
87
|
+
enabled: true,
|
|
88
|
+
density: 0.045,
|
|
89
|
+
sizeRange: Object.freeze([0.13, 0.2]),
|
|
90
|
+
color: Object.freeze([1.0, 0.87, 0.42]),
|
|
91
|
+
hoverHeight: Object.freeze([0.25, 2.2]),
|
|
92
|
+
hoverRadius: 0.9,
|
|
93
|
+
blinkSpeed: 1.0,
|
|
94
|
+
intensity: 1.0,
|
|
95
|
+
windResponse: 0.1,
|
|
96
|
+
gate: 'duskNight',
|
|
97
|
+
}),
|
|
98
|
+
pollen: Object.freeze({
|
|
99
|
+
enabled: true,
|
|
100
|
+
density: 0.06,
|
|
101
|
+
sizeRange: Object.freeze([0.045, 0.085]),
|
|
102
|
+
color: Object.freeze([1.0, 0.93, 0.72]),
|
|
103
|
+
hoverHeight: Object.freeze([0.3, 2.6]),
|
|
104
|
+
driftRadius: 1.3,
|
|
105
|
+
backlitStrength: 1.0,
|
|
106
|
+
windResponse: 0.5,
|
|
107
|
+
gate: 'day',
|
|
108
|
+
}),
|
|
109
|
+
mist: Object.freeze({
|
|
110
|
+
enabled: true,
|
|
111
|
+
density: 0.0045,
|
|
112
|
+
sizeRange: Object.freeze([1.6, 3.0]),
|
|
113
|
+
color: Object.freeze([0.84, 0.9, 0.97]),
|
|
114
|
+
opacity: 0.34,
|
|
115
|
+
scrollSpan: 26,
|
|
116
|
+
marginWidth: 7,
|
|
117
|
+
windResponse: 1.0,
|
|
118
|
+
gate: 'dawnDusk',
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export const AMBIENTFX_SETTING_GROUPS = Object.freeze([
|
|
123
|
+
Object.freeze({
|
|
124
|
+
id: 'shared',
|
|
125
|
+
label: 'Shared',
|
|
126
|
+
description: 'Wind, sun, and the follow-window every effect emits into. Match windDirection/windSpeed/windStrength with the grass and tree wind so the whole world blows the same way.',
|
|
127
|
+
}),
|
|
128
|
+
Object.freeze({
|
|
129
|
+
id: 'petals',
|
|
130
|
+
label: 'Petals',
|
|
131
|
+
description: 'Flutter-falling blossom petals. Emit from registered bloom volumes (flowering canopies) when any exist, otherwise from the open air above the ground.',
|
|
132
|
+
}),
|
|
133
|
+
Object.freeze({
|
|
134
|
+
id: 'leaves',
|
|
135
|
+
label: 'Falling Leaves',
|
|
136
|
+
description: 'Tumble-falling leaves with strong gust response. Emit from bloom volumes tagged effect:"leaves", otherwise globally.',
|
|
137
|
+
}),
|
|
138
|
+
Object.freeze({
|
|
139
|
+
id: 'fireflies',
|
|
140
|
+
label: 'Fireflies',
|
|
141
|
+
description: 'Hovering, blinking emissive motes over grass and shore margins. Unlit by design; they ramp with the time-of-day dusk.',
|
|
142
|
+
}),
|
|
143
|
+
Object.freeze({
|
|
144
|
+
id: 'pollen',
|
|
145
|
+
label: 'Pollen Motes',
|
|
146
|
+
description: 'Slow curl-drifting dust motes, brightest looking toward the sun (backlit). Bind to flower masks via the effects config.',
|
|
147
|
+
}),
|
|
148
|
+
Object.freeze({
|
|
149
|
+
id: 'mist',
|
|
150
|
+
label: 'Ground Mist',
|
|
151
|
+
description: 'Soft horizontal wisps scrolling with the wind, hugging water margins and low ground at dawn/dusk.',
|
|
152
|
+
}),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
const gateField = {
|
|
156
|
+
label: 'Time Gate',
|
|
157
|
+
description: 'When the effect is visible; weights follow the environmentTimeOfDay hour.',
|
|
158
|
+
type: 'select',
|
|
159
|
+
options: [...AMBIENTFX_TIME_GATES],
|
|
160
|
+
optionLabels: { any: 'Always', dawnDusk: 'Dawn & dusk', day: 'Day', duskNight: 'Dusk & night', night: 'Night' },
|
|
161
|
+
};
|
|
162
|
+
const densityField = (max, caption) => ({
|
|
163
|
+
label: 'Density',
|
|
164
|
+
description: caption,
|
|
165
|
+
range: { min: 0, max, step: max / 200 },
|
|
166
|
+
});
|
|
167
|
+
const windResponseField = {
|
|
168
|
+
label: 'Wind Response',
|
|
169
|
+
description: 'Multiplier on the shared wind drift for this effect.',
|
|
170
|
+
range: { min: 0, max: 3, step: 0.05 },
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const FIELD_DEFINITIONS = Object.freeze({
|
|
174
|
+
shared: Object.freeze({
|
|
175
|
+
windDirection: {
|
|
176
|
+
label: 'Wind Direction',
|
|
177
|
+
description: 'Horizontal (XZ) heading the wind blows toward — share with grass/trees. Magnitude is ignored.',
|
|
178
|
+
type: 'vector2',
|
|
179
|
+
},
|
|
180
|
+
windSpeed: {
|
|
181
|
+
label: 'Wind Speed',
|
|
182
|
+
description: 'How fast wind-driven motion oscillates and mist scrolls.',
|
|
183
|
+
range: { min: 0, max: 4, step: 0.01 },
|
|
184
|
+
},
|
|
185
|
+
windStrength: {
|
|
186
|
+
label: 'Wind Strength',
|
|
187
|
+
description: 'How far particles drift downwind.',
|
|
188
|
+
range: { min: 0, max: 1, step: 0.005 },
|
|
189
|
+
},
|
|
190
|
+
sunDirection: {
|
|
191
|
+
label: 'Sun Direction',
|
|
192
|
+
description: 'World-space direction toward the sun (normalized on apply); drives the pollen backlight and petal sheen.',
|
|
193
|
+
type: 'vector3',
|
|
194
|
+
},
|
|
195
|
+
windowRadius: {
|
|
196
|
+
label: 'Window Radius',
|
|
197
|
+
description: 'Meters of the follow window particles exist in around the follow target. Construction-only.',
|
|
198
|
+
range: { min: 15, max: 120, step: 1 },
|
|
199
|
+
},
|
|
200
|
+
maxParticles: {
|
|
201
|
+
label: 'Max Particles',
|
|
202
|
+
description: 'Hard budget; effect densities are scaled down proportionally when their sum would exceed it. Construction-only.',
|
|
203
|
+
range: { min: 1000, max: 40000, step: 500 },
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
petals: Object.freeze({
|
|
207
|
+
enabled: { label: 'Enabled', description: 'Master toggle for the effect.', type: 'boolean' },
|
|
208
|
+
density: densityField(0.15, 'Petals per m³ of the emission volume.'),
|
|
209
|
+
canopyDensity: {
|
|
210
|
+
label: 'Canopy Density',
|
|
211
|
+
description: 'Petals per m³ inside registered bloom volumes (crowns shed far more than open air).',
|
|
212
|
+
range: { min: 0, max: 20, step: 0.1 },
|
|
213
|
+
},
|
|
214
|
+
sizeRange: { label: 'Size Range', description: 'Min/max petal size in meters.', type: 'vector2' },
|
|
215
|
+
colorA: { label: 'Color A', description: 'Primary petal color.', type: 'color' },
|
|
216
|
+
colorB: { label: 'Color B', description: 'Secondary petal color; each petal picks between the two.', type: 'color' },
|
|
217
|
+
emitHeight: {
|
|
218
|
+
label: 'Emit Height',
|
|
219
|
+
description: 'Min/max meters above ground petals spawn at when not bound to canopies. Construction-only.',
|
|
220
|
+
type: 'vector2',
|
|
221
|
+
},
|
|
222
|
+
flutter: { label: 'Flutter', description: 'Side-to-side rocking amplitude while falling.', range: { min: 0, max: 3, step: 0.05 } },
|
|
223
|
+
windResponse: windResponseField,
|
|
224
|
+
gate: gateField,
|
|
225
|
+
}),
|
|
226
|
+
leaves: Object.freeze({
|
|
227
|
+
enabled: { label: 'Enabled', description: 'Master toggle for the effect.', type: 'boolean' },
|
|
228
|
+
density: densityField(0.15, 'Leaves per m³ of the emission volume.'),
|
|
229
|
+
canopyDensity: {
|
|
230
|
+
label: 'Canopy Density',
|
|
231
|
+
description: 'Leaves per m³ inside bloom volumes tagged effect:"leaves".',
|
|
232
|
+
range: { min: 0, max: 20, step: 0.1 },
|
|
233
|
+
},
|
|
234
|
+
sizeRange: { label: 'Size Range', description: 'Min/max leaf size in meters.', type: 'vector2' },
|
|
235
|
+
colorA: { label: 'Color A', description: 'Primary leaf color.', type: 'color' },
|
|
236
|
+
colorB: { label: 'Color B', description: 'Secondary leaf color; each leaf picks between the two.', type: 'color' },
|
|
237
|
+
emitHeight: {
|
|
238
|
+
label: 'Emit Height',
|
|
239
|
+
description: 'Min/max meters above ground leaves spawn at when not bound to canopies. Construction-only.',
|
|
240
|
+
type: 'vector2',
|
|
241
|
+
},
|
|
242
|
+
tumble: { label: 'Tumble', description: 'Rotational tumbling speed while falling.', range: { min: 0, max: 3, step: 0.05 } },
|
|
243
|
+
windResponse: windResponseField,
|
|
244
|
+
gate: gateField,
|
|
245
|
+
}),
|
|
246
|
+
fireflies: Object.freeze({
|
|
247
|
+
enabled: { label: 'Enabled', description: 'Master toggle for the effect.', type: 'boolean' },
|
|
248
|
+
density: densityField(0.2, 'Fireflies per m³ of the near-ground hover band.'),
|
|
249
|
+
sizeRange: { label: 'Size Range', description: 'Min/max glow-sprite size in meters.', type: 'vector2' },
|
|
250
|
+
color: { label: 'Glow Color', description: 'Emissive glow color (unlit; never touched by scene lights).', type: 'color' },
|
|
251
|
+
hoverHeight: { label: 'Hover Height', description: 'Min/max meters above ground fireflies hover at. Construction-only.', type: 'vector2' },
|
|
252
|
+
hoverRadius: { label: 'Hover Radius', description: 'Meters of wander around each spawn point.', range: { min: 0, max: 4, step: 0.05 } },
|
|
253
|
+
blinkSpeed: { label: 'Blink Speed', description: 'How fast the blink program pulses.', range: { min: 0, max: 4, step: 0.05 } },
|
|
254
|
+
intensity: { label: 'Intensity', description: 'Emissive brightness multiplier.', range: { min: 0, max: 4, step: 0.05 } },
|
|
255
|
+
windResponse: windResponseField,
|
|
256
|
+
gate: gateField,
|
|
257
|
+
}),
|
|
258
|
+
pollen: Object.freeze({
|
|
259
|
+
enabled: { label: 'Enabled', description: 'Master toggle for the effect.', type: 'boolean' },
|
|
260
|
+
density: densityField(0.3, 'Motes per m³ of the near-ground drift band.'),
|
|
261
|
+
sizeRange: { label: 'Size Range', description: 'Min/max mote size in meters.', type: 'vector2' },
|
|
262
|
+
color: { label: 'Color', description: 'Mote color (additive, so it reads as light).', type: 'color' },
|
|
263
|
+
hoverHeight: { label: 'Hover Height', description: 'Min/max meters above ground motes drift at. Construction-only.', type: 'vector2' },
|
|
264
|
+
driftRadius: { label: 'Drift Radius', description: 'Meters of curl-drift wander around each spawn point.', range: { min: 0, max: 5, step: 0.05 } },
|
|
265
|
+
backlitStrength: { label: 'Backlit Strength', description: 'Brightness boost when the camera looks toward the sun through the motes.', range: { min: 0, max: 3, step: 0.05 } },
|
|
266
|
+
windResponse: windResponseField,
|
|
267
|
+
gate: gateField,
|
|
268
|
+
}),
|
|
269
|
+
mist: Object.freeze({
|
|
270
|
+
enabled: { label: 'Enabled', description: 'Master toggle for the effect.', type: 'boolean' },
|
|
271
|
+
density: densityField(0.02, 'Wisps per m³ of the ground-hugging band — a few dozen quads, not thousands.'),
|
|
272
|
+
sizeRange: { label: 'Size Range', description: 'Min/max wisp height in meters (width is ~3–5× the height).', type: 'vector2' },
|
|
273
|
+
color: { label: 'Color', description: 'Wisp color.', type: 'color' },
|
|
274
|
+
opacity: { label: 'Opacity', description: 'Peak alpha at a wisp center; the sprite falls off softly from there.', range: { min: 0, max: 0.6, step: 0.01 } },
|
|
275
|
+
scrollSpan: { label: 'Scroll Span', description: 'Meters a wisp travels downwind before wrapping (fades at both ends).', range: { min: 5, max: 60, step: 1 } },
|
|
276
|
+
marginWidth: {
|
|
277
|
+
label: 'Margin Width',
|
|
278
|
+
description: 'Meters of |ground − waterLevel| that count as the water-margin emission band. Construction-only.',
|
|
279
|
+
range: { min: 1, max: 20, step: 0.5 },
|
|
280
|
+
},
|
|
281
|
+
windResponse: windResponseField,
|
|
282
|
+
gate: gateField,
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
function createAmbientFxFieldMetadata(group, key, field) {
|
|
287
|
+
const defaultValue = DEFAULT_AMBIENTFX_SETTINGS[group.id][key];
|
|
288
|
+
return Object.freeze({
|
|
289
|
+
defaultValue: Array.isArray(defaultValue) ? [...defaultValue] : defaultValue,
|
|
290
|
+
description: field.description,
|
|
291
|
+
group: group.id,
|
|
292
|
+
id: `${group.id}.${key}`,
|
|
293
|
+
key,
|
|
294
|
+
label: field.label,
|
|
295
|
+
optionLabels: field.optionLabels ?? null,
|
|
296
|
+
options: field.options ?? null,
|
|
297
|
+
range: field.range ?? null,
|
|
298
|
+
serializable: true,
|
|
299
|
+
type: field.type ?? (typeof defaultValue === 'boolean' ? 'boolean'
|
|
300
|
+
: typeof defaultValue === 'number' ? 'number' : 'text'),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export const AMBIENTFX_SETTING_FIELD_SCHEMA = Object.freeze(
|
|
305
|
+
Object.fromEntries(
|
|
306
|
+
AMBIENTFX_SETTING_GROUPS.map((group) => [
|
|
307
|
+
group.id,
|
|
308
|
+
Object.freeze(
|
|
309
|
+
Object.fromEntries(
|
|
310
|
+
Object.entries(FIELD_DEFINITIONS[group.id] ?? {})
|
|
311
|
+
.map(([key, field]) => [key, createAmbientFxFieldMetadata(group, key, field)]),
|
|
312
|
+
),
|
|
313
|
+
),
|
|
314
|
+
]),
|
|
315
|
+
),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
function cloneValue(value) {
|
|
319
|
+
if (Array.isArray(value)) return value.map(cloneValue);
|
|
320
|
+
if (value && typeof value === 'object') return Object.fromEntries(
|
|
321
|
+
Object.entries(value).map(([key, entry]) => [key, cloneValue(entry)]),
|
|
322
|
+
);
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function cloneAmbientFxSettings(settings = DEFAULT_AMBIENTFX_SETTINGS) {
|
|
327
|
+
return cloneValue(settings);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function clampNumber(value, fallback, min = -Infinity, max = Infinity) {
|
|
331
|
+
const number = Number(value);
|
|
332
|
+
if (!Number.isFinite(number)) return fallback;
|
|
333
|
+
return Math.min(max, Math.max(min, number));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Normalizes nested partial overrides over the defaults; unknown keys are
|
|
338
|
+
* dropped, malformed values fall back. Presets resolve in
|
|
339
|
+
* `createAmbientFx({ preset })` (see ambientFxPresets.js) — this function is
|
|
340
|
+
* the plain merge/validate step.
|
|
341
|
+
*/
|
|
342
|
+
export function createAmbientFxSettings(overrides = {}) {
|
|
343
|
+
const result = cloneAmbientFxSettings(DEFAULT_AMBIENTFX_SETTINGS);
|
|
344
|
+
for (const groupId of Object.keys(result)) {
|
|
345
|
+
const group = overrides?.[groupId];
|
|
346
|
+
if (!group || typeof group !== 'object') continue;
|
|
347
|
+
for (const key of Object.keys(result[groupId])) {
|
|
348
|
+
if (group[key] === undefined) continue;
|
|
349
|
+
result[groupId][key] = cloneValue(group[key]);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const shared = result.shared;
|
|
353
|
+
shared.windowRadius = clampNumber(shared.windowRadius, 45, 10, 200);
|
|
354
|
+
shared.maxParticles = Math.round(clampNumber(shared.maxParticles, 20000, 100, 200000));
|
|
355
|
+
shared.windSpeed = clampNumber(shared.windSpeed, 1);
|
|
356
|
+
shared.windStrength = clampNumber(shared.windStrength, 0.16, 0);
|
|
357
|
+
for (const id of AMBIENTFX_EFFECT_IDS) {
|
|
358
|
+
const effect = result[id];
|
|
359
|
+
effect.enabled = Boolean(effect.enabled);
|
|
360
|
+
effect.density = clampNumber(effect.density, 0, 0);
|
|
361
|
+
effect.windResponse = clampNumber(effect.windResponse, 1, 0);
|
|
362
|
+
if (!AMBIENTFX_TIME_GATES.includes(effect.gate)) {
|
|
363
|
+
effect.gate = DEFAULT_AMBIENTFX_SETTINGS[id].gate;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
result.mist.opacity = clampNumber(result.mist.opacity, 0.34, 0, 1);
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Source-aware emission for the ambient-fx backbone. Emitters own WHERE
|
|
2
|
+
// particles spawn (and their per-particle rng stream); stylizedAmbientFx owns
|
|
3
|
+
// what they look like. Everything here is pure CPU + deterministic, so the
|
|
4
|
+
// verify script exercises it headless.
|
|
5
|
+
//
|
|
6
|
+
// Determinism is world-anchored: the window disk is diced into fixed world
|
|
7
|
+
// cells and each cell hashes (seed, effect, cellX, cellZ) into its own rng
|
|
8
|
+
// stream. Re-emitting after the follow target strays therefore reproduces
|
|
9
|
+
// BIT-IDENTICAL particles in the overlapping region — cells only appear or
|
|
10
|
+
// drop at the disk rim, where the shader's window fade has already taken
|
|
11
|
+
// them to zero. That invariant (recenter distance + fade end ≤ disk radius)
|
|
12
|
+
// is what makes window re-emission pop-free.
|
|
13
|
+
|
|
14
|
+
import { hashCombine, mulberry32 } from './particleBackbone.js';
|
|
15
|
+
|
|
16
|
+
function cellHash(seed, kindId, ix, iz) {
|
|
17
|
+
let h = hashCombine(seed >>> 0, (kindId + 1) >>> 0);
|
|
18
|
+
h = hashCombine(h, (ix + 0x40000000) >>> 0);
|
|
19
|
+
h = hashCombine(h, (iz + 0x40000000) >>> 0);
|
|
20
|
+
return h;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function inBounds(bounds, x, z) {
|
|
24
|
+
if (!bounds) return true;
|
|
25
|
+
return Math.abs(x) <= bounds.x && Math.abs(z) <= bounds.z;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function groundY(heightAt, x, z) {
|
|
29
|
+
const y = typeof heightAt === 'function' ? Number(heightAt(x, z)) : 0;
|
|
30
|
+
return Number.isFinite(y) ? y : 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Global-volume emitter: `density` particles per m³ of (window disk area ×
|
|
35
|
+
* `band` height), spawned `band[0]..band[1]` meters above the ground.
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} options
|
|
38
|
+
* @param {number} options.seed World seed.
|
|
39
|
+
* @param {number} options.kindId Effect kind (keeps rng streams apart).
|
|
40
|
+
* @param {{x: number, z: number}} options.center Window center.
|
|
41
|
+
* @param {number} options.radius Window radius in meters.
|
|
42
|
+
* @param {number} options.density Particles per m³.
|
|
43
|
+
* @param {[number, number]} options.band Min/max meters above ground.
|
|
44
|
+
* @param {Function} options.heightAt `(x, z) => y` terrain sampler.
|
|
45
|
+
* @param {Function} [options.mask] `(x, z) => boolean` keep filter.
|
|
46
|
+
* @param {{x: number, z: number}} [options.bounds] World half-extents clamp.
|
|
47
|
+
* @param {Function} [options.yBase] Overrides the ground sample (e.g. water).
|
|
48
|
+
* @param {Function} options.emit `(x, y, z, rng, extra) => void`.
|
|
49
|
+
* @returns {number} Emitted count.
|
|
50
|
+
*/
|
|
51
|
+
export function emitGlobalVolume({
|
|
52
|
+
seed = 1, kindId = 0, center, radius, density = 0, band = [0, 1],
|
|
53
|
+
heightAt, mask = null, bounds = null, yBase = null, emit,
|
|
54
|
+
}) {
|
|
55
|
+
if (!(density > 0) || !(radius > 0)) return 0;
|
|
56
|
+
// Cells small enough that the rim of appearing/dropping cells sits inside
|
|
57
|
+
// the fade band, big enough that cell loops stay trivial.
|
|
58
|
+
const cell = Math.max(radius / 4, 6);
|
|
59
|
+
const bandMin = Math.min(band[0], band[1]);
|
|
60
|
+
const bandSpan = Math.abs(band[1] - band[0]);
|
|
61
|
+
const perCell = density * cell * cell * Math.max(bandSpan, 0.25);
|
|
62
|
+
const radiusSq = radius * radius;
|
|
63
|
+
const minIx = Math.floor((center.x - radius) / cell);
|
|
64
|
+
const maxIx = Math.floor((center.x + radius) / cell);
|
|
65
|
+
const minIz = Math.floor((center.z - radius) / cell);
|
|
66
|
+
const maxIz = Math.floor((center.z + radius) / cell);
|
|
67
|
+
let emitted = 0;
|
|
68
|
+
for (let ix = minIx; ix <= maxIx; ix += 1) {
|
|
69
|
+
for (let iz = minIz; iz <= maxIz; iz += 1) {
|
|
70
|
+
const h = cellHash(seed, kindId, ix, iz);
|
|
71
|
+
const rng = mulberry32(h);
|
|
72
|
+
const n = Math.floor(perCell) + (rng() < perCell % 1 ? 1 : 0);
|
|
73
|
+
for (let i = 0; i < n; i += 1) {
|
|
74
|
+
// Candidate positions draw from the cell stream in fixed order; all
|
|
75
|
+
// OTHER randomness comes from a per-candidate child rng. Rejection
|
|
76
|
+
// (window disk, mask, bounds) therefore can never shift a later
|
|
77
|
+
// particle's draws — the invariant behind pop-free re-emission.
|
|
78
|
+
const x = (ix + rng()) * cell;
|
|
79
|
+
const z = (iz + rng()) * cell;
|
|
80
|
+
const lift = bandMin + rng() * bandSpan;
|
|
81
|
+
const dx = x - center.x;
|
|
82
|
+
const dz = z - center.z;
|
|
83
|
+
if (dx * dx + dz * dz > radiusSq) continue;
|
|
84
|
+
if (!inBounds(bounds, x, z)) continue;
|
|
85
|
+
if (mask && !mask(x, z)) continue;
|
|
86
|
+
const base = yBase ? yBase(x, z) : groundY(heightAt, x, z);
|
|
87
|
+
emit(x, base + lift, z, mulberry32(hashCombine(h, i >>> 0)), null);
|
|
88
|
+
emitted += 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return emitted;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Mask-bound emitter: global-volume restricted to `(x, z) => bool` regions
|
|
96
|
+
* (flower masks, grass patches, shore margins from the host's mask kit). */
|
|
97
|
+
export function emitMaskBound(options) {
|
|
98
|
+
if (typeof options?.mask !== 'function') return 0;
|
|
99
|
+
return emitGlobalVolume(options);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Water-margin band: emits where |ground − waterLevel| < `margin`, with the
|
|
104
|
+
* spawn base hugging whichever is higher — the water surface over the lake,
|
|
105
|
+
* the bank on shore. The natural home of mist wisps and dusk fireflies.
|
|
106
|
+
*/
|
|
107
|
+
export function emitWaterMargin({ waterLevel, margin = 6, heightAt, ...rest }) {
|
|
108
|
+
if (!Number.isFinite(waterLevel)) return 0;
|
|
109
|
+
return emitGlobalVolume({
|
|
110
|
+
...rest,
|
|
111
|
+
heightAt,
|
|
112
|
+
mask: (x, z) => Math.abs(groundY(heightAt, x, z) - waterLevel) < margin,
|
|
113
|
+
yBase: (x, z) => Math.max(groundY(heightAt, x, z), waterLevel),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Canopy-bound emitter over registered bloom volumes.
|
|
119
|
+
*
|
|
120
|
+
* A bloom volume is a plain `{ x, y, z, radius, color?, effect? }` object —
|
|
121
|
+
* `x/y/z` the crown center in world space, `radius` its rough sphere radius,
|
|
122
|
+
* `color` an optional `[r, g, b]` (sRGB 0..1) the shed particles inherit
|
|
123
|
+
* (blossom pink, autumn gold), `effect` which effect it feeds ('petals' by
|
|
124
|
+
* default, or 'leaves'). Hosts register them via fx.addBloomSources(); tree
|
|
125
|
+
* recipes that know their bloom color can hand these straight over.
|
|
126
|
+
*
|
|
127
|
+
* `density` is particles per m³ of each crown's upper half — crowns shed far
|
|
128
|
+
* more than open air, hence the separate canopyDensity setting.
|
|
129
|
+
*/
|
|
130
|
+
export function emitCanopyBound({
|
|
131
|
+
seed = 1, kindId = 0, sources = [], center, radius, density = 0,
|
|
132
|
+
bounds = null, emit,
|
|
133
|
+
}) {
|
|
134
|
+
if (!(density > 0) || !Array.isArray(sources) || sources.length === 0) return 0;
|
|
135
|
+
const radiusSq = radius * radius;
|
|
136
|
+
let emitted = 0;
|
|
137
|
+
sources.forEach((source, index) => {
|
|
138
|
+
const r = Math.max(Number(source?.radius) || 0, 0.4);
|
|
139
|
+
const dx = (source.x ?? 0) - center.x;
|
|
140
|
+
const dz = (source.z ?? 0) - center.z;
|
|
141
|
+
if (dx * dx + dz * dz > (radius + r) * (radius + r)) return;
|
|
142
|
+
const h = hashCombine(hashCombine(seed >>> 0, (kindId + 101) >>> 0), index >>> 0);
|
|
143
|
+
const rng = mulberry32(h);
|
|
144
|
+
const halfVolume = (2 / 3) * Math.PI * r * r * r;
|
|
145
|
+
const target = density * halfVolume;
|
|
146
|
+
const n = Math.floor(target) + (rng() < target % 1 ? 1 : 0);
|
|
147
|
+
const extra = Array.isArray(source.color) ? { color: source.color } : null;
|
|
148
|
+
for (let i = 0; i < n; i += 1) {
|
|
149
|
+
// Shed from the crown shell, biased to its upper half — petals leave
|
|
150
|
+
// from where the blossoms are, not the trunk. Positions draw from the
|
|
151
|
+
// source stream; everything else from a per-candidate child rng (see
|
|
152
|
+
// emitGlobalVolume for why).
|
|
153
|
+
const theta = rng() * Math.PI * 2;
|
|
154
|
+
const up = rng() * 1.3 - 0.3;
|
|
155
|
+
const shell = r * (0.55 + 0.45 * Math.cbrt(rng()));
|
|
156
|
+
const horizontal = Math.sqrt(Math.max(1 - up * up, 0)) * shell;
|
|
157
|
+
const x = source.x + Math.cos(theta) * horizontal;
|
|
158
|
+
const z = source.z + Math.sin(theta) * horizontal;
|
|
159
|
+
const y = source.y + up * shell;
|
|
160
|
+
const cx = x - center.x;
|
|
161
|
+
const cz = z - center.z;
|
|
162
|
+
if (cx * cx + cz * cz > radiusSq) continue;
|
|
163
|
+
if (!inBounds(bounds, x, z)) continue;
|
|
164
|
+
emit(x, y, z, mulberry32(hashCombine(h, i >>> 0)), extra);
|
|
165
|
+
emitted += 1;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
return emitted;
|
|
169
|
+
}
|