@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.
Files changed (205) hide show
  1. package/AGENTS.md +127 -4
  2. package/ATTRIBUTION.md +41 -3
  3. package/README.md +234 -34
  4. package/docs/characters.md +144 -0
  5. package/docs/debug-panel.md +126 -0
  6. package/docs/docs.css +589 -0
  7. package/docs/environment.md +186 -0
  8. package/docs/getting-started.md +180 -0
  9. package/docs/index.html +14 -0
  10. package/docs/lab-architecture.md +100 -0
  11. package/docs/lighting.md +825 -0
  12. package/docs/main.jsx +739 -0
  13. package/docs/mcp.md +97 -0
  14. package/docs/post-processing.md +98 -0
  15. package/docs/settings-reference.md +1878 -0
  16. package/docs/shader-constants.md +77 -0
  17. package/docs/sky.md +182 -0
  18. package/docs/style-labs.md +309 -0
  19. package/docs/texture-lab.md +135 -0
  20. package/docs/toon-shading.md +183 -0
  21. package/docs/tsl-conventions.md +167 -0
  22. package/docs/vegetation-sky.md +275 -0
  23. package/docs/water.md +430 -0
  24. package/docs/weather.md +200 -0
  25. package/docs/world-scale.md +111 -0
  26. package/mcp/server.mjs +610 -0
  27. package/mcp/style-lab-tools.mjs +371 -0
  28. package/mcp/vite-plugin.mjs +174 -0
  29. package/mcp/workspace.mjs +397 -0
  30. package/package.json +64 -5
  31. package/src/ambientfx/INTEGRATION.md +164 -0
  32. package/src/ambientfx/ambientFxPresets.js +83 -0
  33. package/src/ambientfx/ambientFxSettings.js +368 -0
  34. package/src/ambientfx/emitters.js +169 -0
  35. package/src/ambientfx/index.js +5 -0
  36. package/src/ambientfx/particleBackbone.js +493 -0
  37. package/src/ambientfx/stylizedAmbientFx.js +450 -0
  38. package/src/assetlib/ambientcg.js +117 -0
  39. package/src/assetlib/assetRef.js +91 -0
  40. package/src/assetlib/importedEntry.js +40 -0
  41. package/src/assetlib/index.js +23 -0
  42. package/src/assetlib/kaykit.js +192 -0
  43. package/src/assetlib/kaykitStaticIndex.js +700 -0
  44. package/src/assetlib/loadImported.js +143 -0
  45. package/src/assetlib/opensource3d.js +113 -0
  46. package/src/assetlib/polyhaven.js +182 -0
  47. package/src/assetlib/polypizza.js +115 -0
  48. package/src/assetlib/smithsonian.js +214 -0
  49. package/src/assetlib/sources.js +279 -0
  50. package/src/assetlib/zip.js +58 -0
  51. package/src/biome/biomeGenerator.js +385 -0
  52. package/src/biome/biomeRuntime.js +299 -0
  53. package/src/biome/index.js +2 -0
  54. package/src/buildinggen/buildingAsset.js +44 -0
  55. package/src/buildinggen/buildingGrammar.js +311 -0
  56. package/src/buildinggen/buildingMesh.js +451 -0
  57. package/src/buildinggen/buildingPresets.js +46 -0
  58. package/src/buildinggen/buildingRecipe.js +100 -0
  59. package/src/buildinggen/buildingSettings.js +238 -0
  60. package/src/buildinggen/index.js +6 -0
  61. package/src/camera/cameraDirector.js +157 -0
  62. package/src/camera/cameraGenerator.js +367 -0
  63. package/src/camera/cameraRig.js +570 -0
  64. package/src/camera/cameraSettings.js +236 -0
  65. package/src/camera/index.js +7 -0
  66. package/src/catalog/builtinEntries.js +245 -0
  67. package/src/catalog/catalog.js +210 -0
  68. package/src/catalog/index.js +3 -0
  69. package/src/catalog/manifest.js +84 -0
  70. package/src/core/generation.js +529 -0
  71. package/src/environment/environmentRigs.js +21 -1
  72. package/src/environment/environmentSettings.js +4 -0
  73. package/src/environment/environmentSunShadowPass.js +8 -0
  74. package/src/fauna/INTEGRATION.md +174 -0
  75. package/src/fauna/boids.js +861 -0
  76. package/src/fauna/faunaBodies.js +492 -0
  77. package/src/fauna/faunaPresets.js +52 -0
  78. package/src/fauna/faunaSettings.js +525 -0
  79. package/src/fauna/index.js +5 -0
  80. package/src/fauna/stylizedFauna.js +395 -0
  81. package/src/game-feel/gameFeelGenerator.js +402 -0
  82. package/src/game-feel/gameFeelRuntime.js +549 -0
  83. package/src/game-feel/index.js +2 -0
  84. package/src/index.js +17 -6
  85. package/src/lighting/colorIntensity.js +177 -0
  86. package/src/lighting/index.js +161 -0
  87. package/src/lighting/lightDescriptors.js +249 -0
  88. package/src/lighting/lightingCapabilities.js +79 -0
  89. package/src/lighting/lightingDocuments.js +247 -0
  90. package/src/lighting/lightingFixtures.js +446 -0
  91. package/src/lighting/lightingGenerator.js +449 -0
  92. package/src/lighting/lightingPresets.js +319 -0
  93. package/src/lighting/lightingRuntime.js +723 -0
  94. package/src/lighting/lightingStyle.js +386 -0
  95. package/src/lighting/lightingSystem.js +774 -0
  96. package/src/lighting/unrealExport.js +186 -0
  97. package/src/lighting/utils.js +87 -0
  98. package/src/motion/index.js +5 -0
  99. package/src/motion/motionClip.js +441 -0
  100. package/src/motion/motionController.js +628 -0
  101. package/src/motion/motionDocuments.js +225 -0
  102. package/src/motion/motionGraph.js +307 -0
  103. package/src/motion/motionSettings.js +222 -0
  104. package/src/pathgen/index.js +7 -0
  105. package/src/pathgen/pathBridge.js +232 -0
  106. package/src/pathgen/pathPresets.js +35 -0
  107. package/src/pathgen/pathRibbon.js +410 -0
  108. package/src/pathgen/pathRouter.js +380 -0
  109. package/src/pathgen/pathSettings.js +335 -0
  110. package/src/pathgen/pathTextures.js +123 -0
  111. package/src/pathgen/stylizedPaths.js +453 -0
  112. package/src/post/index.js +1 -0
  113. package/src/post/postGenerator.js +177 -0
  114. package/src/post/postProcessing.js +41 -0
  115. package/src/propgen/generatorsWave1.js +379 -0
  116. package/src/propgen/generatorsWave2.js +462 -0
  117. package/src/propgen/index.js +5 -0
  118. package/src/propgen/propAsset.js +323 -0
  119. package/src/propgen/propParts.js +170 -0
  120. package/src/propgen/propPlacement.js +459 -0
  121. package/src/propgen/propPresets.js +82 -0
  122. package/src/propgen/propSettings.js +395 -0
  123. package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
  124. package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
  125. package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
  126. package/src/shaders-tsl/chunks/water-waves.js +90 -11
  127. package/src/shaders-tsl/environment.js +15 -1
  128. package/src/shaders-tsl/flower.js +279 -30
  129. package/src/shaders-tsl/grass.js +60 -33
  130. package/src/shaders-tsl/sky.js +125 -31
  131. package/src/shaders-tsl/tree-leaf.js +61 -25
  132. package/src/shaders-tsl/water-breaker.js +7 -4
  133. package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
  134. package/src/shaders-tsl/water.js +439 -49
  135. package/src/shaders-tsl/woody-surface.js +154 -0
  136. package/src/sky/sceneOverrideLayers.js +10 -0
  137. package/src/sky/skyQuality.js +26 -0
  138. package/src/sky/stylizedSky.js +753 -45
  139. package/src/soundscape/index.js +4 -0
  140. package/src/soundscape/soundscapeGenerator.js +179 -0
  141. package/src/soundscape/soundscapeRuntime.js +806 -0
  142. package/src/soundscape/soundscapeSettings.js +292 -0
  143. package/src/styles/index.js +13 -0
  144. package/src/styles/styleBundle.js +325 -0
  145. package/src/stylizedTerrain.js +32 -2
  146. package/src/stylizedWorld.js +423 -20
  147. package/src/texgen/evaluateTexture.js +675 -0
  148. package/src/texgen/index.js +60 -0
  149. package/src/texgen/noise2.js +210 -0
  150. package/src/texgen/textureAi.js +436 -0
  151. package/src/texgen/textureGenerators.js +516 -0
  152. package/src/texgen/texturePresets.js +490 -0
  153. package/src/texgen/textureSettings.js +342 -0
  154. package/src/texgen/textureThree.js +59 -0
  155. package/src/vegetation/flowerSpecies.js +15 -3
  156. package/src/vegetation/grassPalettes.js +153 -0
  157. package/src/vegetation/index.js +6 -0
  158. package/src/vegetation/stylizedBush.js +2 -0
  159. package/src/vegetation/stylizedFlower.js +82 -0
  160. package/src/vegetation/stylizedFlowers.js +48 -7
  161. package/src/vegetation/stylizedForest.js +29 -1
  162. package/src/vegetation/stylizedGrass.js +291 -56
  163. package/src/vegetation/stylizedTree.js +38 -2
  164. package/src/vegetation/stylizedTreeFoliage.js +2 -1
  165. package/src/vegetation/vegetationShaders.js +1110 -0
  166. package/src/vfxgen/INTEGRATION.md +145 -0
  167. package/src/vfxgen/core/burstBackbone.js +380 -0
  168. package/src/vfxgen/core/projectileCore.js +92 -0
  169. package/src/vfxgen/core/spriteShapes.js +98 -0
  170. package/src/vfxgen/core/trailRibbon.js +272 -0
  171. package/src/vfxgen/core/vfxRandom.js +29 -0
  172. package/src/vfxgen/effects/emitHelpers.js +37 -0
  173. package/src/vfxgen/effects/magicEffects.js +162 -0
  174. package/src/vfxgen/effects/movementEffects.js +87 -0
  175. package/src/vfxgen/effects/weaponEffects.js +118 -0
  176. package/src/vfxgen/index.js +18 -0
  177. package/src/vfxgen/moves/moveController.js +146 -0
  178. package/src/vfxgen/moves/moveLibrary.js +335 -0
  179. package/src/vfxgen/vfxPresets.js +98 -0
  180. package/src/vfxgen/vfxSettings.js +384 -0
  181. package/src/vfxgen/vfxSystem.js +449 -0
  182. package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
  183. package/src/villagegen/index.js +4 -0
  184. package/src/villagegen/stylizedVillage.js +490 -0
  185. package/src/villagegen/villageArchetypes.js +160 -0
  186. package/src/villagegen/villageNames.js +40 -0
  187. package/src/villagegen/villageSites.js +105 -0
  188. package/src/water/sceneOverrideLayers.js +23 -0
  189. package/src/water/water.js +5 -0
  190. package/src/water/waterBreakerSystem.js +15 -1
  191. package/src/water/waterCurrentField.js +447 -0
  192. package/src/water/waterMaterial.js +12 -0
  193. package/src/water/waterNearshorePhase.js +320 -0
  194. package/src/water/waterScenePasses.js +83 -30
  195. package/src/water/waterSettings.js +325 -13
  196. package/src/water/waterShoreMaterial.js +322 -0
  197. package/src/water/waterShoreStateField.js +605 -0
  198. package/src/water/waterSurface.js +797 -28
  199. package/src/weather/index.js +6 -0
  200. package/src/weather/weatherPrecipitation.js +221 -0
  201. package/src/weather/weatherPresets.js +258 -0
  202. package/src/weather/weatherSettings.js +269 -0
  203. package/src/weather/weatherSystem.js +871 -0
  204. package/src/worldMinimap.js +62 -0
  205. package/src/worldPresets.js +4 -1
@@ -1,6 +1,17 @@
1
1
  import * as THREE from 'three';
2
2
 
3
+ import {
4
+ createSettingsPresetDocument,
5
+ parsePresetDocument,
6
+ serializePresetDocument,
7
+ validateSettingsPresetDocument,
8
+ } from '../core/presetDocuments.js';
3
9
  import { createSkyNodeMaterial } from '../shaders-tsl/sky.js';
10
+ import { SKY_SCENE_OVERRIDE_PRIORITIES } from './sceneOverrideLayers.js';
11
+ import { resolveSkyQuality } from './skyQuality.js';
12
+
13
+ export { SKY_SCENE_OVERRIDE_PRIORITIES } from './sceneOverrideLayers.js';
14
+ export { SKY_QUALITY_OPTIONS, SKY_QUALITY_TIERS, resolveSkyQuality } from './skyQuality.js';
4
15
 
5
16
  // Procedural stylized sky dome: vertical gradient, sun disc, painterly
6
17
  // two-tone clouds, and stars — no texture assets. Designed as the companion
@@ -25,31 +36,58 @@ export const DEFAULT_SKY_SETTINGS = Object.freeze({
25
36
  sunDirection: [0.35, 0.8, 0.45],
26
37
  sunColor: [1.0, 0.95, 0.82],
27
38
  sunSize: 0.026,
39
+ sunDiscSoftness: 0.5,
28
40
  sunGlowStrength: 1.0,
41
+ sunDiscIntensity: 2.4,
42
+ sunGlowSpread: 5.0,
43
+ sunGlowCoreSharpness: 60.0,
44
+ sunGlowBroadStrength: 0.16,
45
+ sunGlowCoreStrength: 0.5,
46
+ sunCloudOcclusionStrength: 1.0,
29
47
  horizonScattering: 0.5,
48
+ zenithExponent: 0.48,
49
+ groundExponent: 0.55,
50
+ horizonBandSize: 0.42,
51
+ horizonSunPower: 5.0,
30
52
  cloudCoverage: 0.42,
31
53
  cloudScale: 1.6,
32
54
  cloudSpeed: 1.0,
55
+ cloudDirection: Object.freeze([0.9615239476, 0.2747211279]),
56
+ cloudSeed: 0,
57
+ cloudProjection: 0.22,
58
+ cloudSoftness: 0.1,
59
+ cloudEdgeOpacity: 0.65,
60
+ cloudOpacity: 1.0,
61
+ cloudShadeStrength: 0.85,
62
+ cloudShadeThreshold: 0.02,
63
+ cloudShadeSoftness: 0.06,
64
+ cloudLightOffset: 0.4,
65
+ cloudSilverLiningStrength: 0.3,
66
+ cloudSunPower: 10.0,
67
+ cloudHorizonFade: 0.16,
33
68
  cloudColor: [1.0, 1.0, 1.0],
34
69
  cloudShadeColor: [0.68, 0.78, 0.92],
35
70
  starsStrength: 0.0,
71
+ starsColor: Object.freeze([1.0, 0.98, 0.92]),
72
+ starsSeed: 0,
73
+ starsScale: 14.0,
74
+ starsDensity: 0.28,
75
+ starsSize: 0.06,
76
+ starsTwinkleStrength: 0.8,
77
+ starsTwinkleSpeed: 1.0,
78
+ starsHorizonFade: 0.24,
36
79
  });
37
80
 
38
- // Named sky presets: 'default' is the baseline; 'call_me_sensei' is the
39
- // studio-managed signature look, curated and updated over releases.
40
- // Community presets register alongside them via registerSkyPreset().
41
- const skyPresetRegistry = new Map([
42
- ['default', Object.freeze({
43
- description: 'Baseline daytime sky.',
44
- label: 'Default',
45
- settings: Object.freeze({}),
46
- })],
47
- ['call_me_sensei', Object.freeze({
48
- description: 'Studio-managed signature sky, curated by Call Me Sensei and updated over releases. Currently the tuned library defaults.',
49
- label: 'Call Me Sensei',
50
- settings: Object.freeze({}),
51
- })],
52
- ]);
81
+ /** Document `type` discriminator for portable sky-look presets. */
82
+ export const SKY_PRESET_DOCUMENT_TYPE = 'toonlab/sky-preset';
83
+
84
+ /** Current portable sky preset schema version. */
85
+ export const SKY_PRESET_SCHEMA_VERSION = 1;
86
+
87
+ // Named sky looks. Built-ins and imported preset documents use the same
88
+ // registry so a preset behaves identically whether it shipped with Toonlab or
89
+ // was authored in a lab and registered at runtime.
90
+ const skyPresetRegistry = new Map();
53
91
 
54
92
  /**
55
93
  * Registers a named sky preset so it resolves in `createSkySettings({
@@ -57,20 +95,17 @@ const skyPresetRegistry = new Map([
57
95
  * settings? }` or flat settings.
58
96
  */
59
97
  export function registerSkyPreset(name, preset = {}, { overwrite = false } = {}) {
60
- const id = String(name ?? '').trim();
61
- if (!id) throw new Error('Sky preset name is required.');
62
- if (!overwrite && skyPresetRegistry.has(id)) {
63
- throw new Error(`Sky preset "${id}" already exists.`);
98
+ const document = createSkyPresetDocument(name, preset);
99
+ if (!overwrite && skyPresetRegistry.has(document.id)) {
100
+ throw new Error(`Sky preset "${document.id}" already exists.`);
64
101
  }
65
- const source = preset && typeof preset === 'object' ? preset : {};
66
- const { label, description, settings, ...flat } = source;
67
102
  const entry = Object.freeze({
68
- description: typeof description === 'string' ? description : '',
69
- label: typeof label === 'string' && label ? label : id,
70
- settings: Object.freeze({ ...(settings && typeof settings === 'object' ? settings : flat) }),
103
+ description: document.description,
104
+ label: document.label,
105
+ settings: Object.freeze({ ...document.settings }),
71
106
  });
72
- skyPresetRegistry.set(id, entry);
73
- return { description: entry.description, id, label: entry.label };
107
+ skyPresetRegistry.set(document.id, entry);
108
+ return { description: entry.description, id: document.id, label: entry.label };
74
109
  }
75
110
 
76
111
  /** Lists registered sky presets as `{ id, label, description }` (for HUDs). */
@@ -82,9 +117,9 @@ export function getSkyPresetOptions() {
82
117
  }));
83
118
  }
84
119
 
85
- function finiteNumber(value, fallback) {
120
+ function finiteNumber(value, fallback, { max = Infinity, min = -Infinity } = {}) {
86
121
  const number = Number(value);
87
- return Number.isFinite(number) ? number : fallback;
122
+ return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
88
123
  }
89
124
 
90
125
  function colorArray(value, fallback) {
@@ -113,6 +148,29 @@ function vector3Array(value, fallback) {
113
148
  return fallback.slice();
114
149
  }
115
150
 
151
+ function vector2Array(value, fallback) {
152
+ if (value?.isVector2) return [value.x, value.y];
153
+ if (Array.isArray(value) && value.length >= 2) {
154
+ const next = value.slice(0, 2).map(Number);
155
+ return next.every(Number.isFinite) ? next : fallback.slice();
156
+ }
157
+ return fallback.slice();
158
+ }
159
+
160
+ function direction2Array(value, fallback) {
161
+ const supplied = value?.isVector2 || (
162
+ Array.isArray(value)
163
+ && value.length >= 2
164
+ && value.slice(0, 2).every((channel) => Number.isFinite(Number(channel)))
165
+ );
166
+ if (!supplied) return fallback.slice();
167
+ const next = vector2Array(value, fallback);
168
+ const length = Math.hypot(next[0], next[1]);
169
+ if (length < 1e-6) return fallback.slice();
170
+ return [next[0] / length, next[1] / length]
171
+ .map((channel) => Number(channel.toFixed(10)));
172
+ }
173
+
116
174
  /**
117
175
  * Validates and merges partial sky options over {@link DEFAULT_SKY_SETTINGS}.
118
176
  * Unknown keys are ignored; malformed values fall back to their defaults.
@@ -123,7 +181,9 @@ function vector3Array(value, fallback) {
123
181
  * @returns {Object} A complete, plain sky settings object.
124
182
  */
125
183
  export function createSkySettings(options = {}) {
126
- const source = options && typeof options === 'object' ? options : {};
184
+ const source = typeof options === 'string'
185
+ ? { preset: options }
186
+ : (options && typeof options === 'object' ? options : {});
127
187
  const presetSettings = skyPresetRegistry.get(source.preset)?.settings;
128
188
  const base = presetSettings ? { ...DEFAULT_SKY_SETTINGS, ...presetSettings } : DEFAULT_SKY_SETTINGS;
129
189
  return {
@@ -133,15 +193,47 @@ export function createSkySettings(options = {}) {
133
193
  groundColor: colorArray(source.groundColor, base.groundColor),
134
194
  sunDirection: vector3Array(source.sunDirection, base.sunDirection),
135
195
  sunColor: colorArray(source.sunColor, base.sunColor),
136
- sunSize: finiteNumber(source.sunSize, base.sunSize),
137
- sunGlowStrength: finiteNumber(source.sunGlowStrength, base.sunGlowStrength),
138
- horizonScattering: finiteNumber(source.horizonScattering, base.horizonScattering),
139
- cloudCoverage: finiteNumber(source.cloudCoverage, base.cloudCoverage),
140
- cloudScale: finiteNumber(source.cloudScale, base.cloudScale),
141
- cloudSpeed: finiteNumber(source.cloudSpeed, base.cloudSpeed),
196
+ sunSize: finiteNumber(source.sunSize, base.sunSize, { max: 0.2, min: 0 }),
197
+ sunDiscSoftness: finiteNumber(source.sunDiscSoftness, base.sunDiscSoftness, { max: 1, min: 0.01 }),
198
+ sunGlowStrength: finiteNumber(source.sunGlowStrength, base.sunGlowStrength, { max: 4, min: 0 }),
199
+ sunDiscIntensity: finiteNumber(source.sunDiscIntensity, base.sunDiscIntensity, { max: 8, min: 0 }),
200
+ sunGlowSpread: finiteNumber(source.sunGlowSpread, base.sunGlowSpread, { max: 20, min: 1 }),
201
+ sunGlowCoreSharpness: finiteNumber(source.sunGlowCoreSharpness, base.sunGlowCoreSharpness, { max: 200, min: 5 }),
202
+ sunGlowBroadStrength: finiteNumber(source.sunGlowBroadStrength, base.sunGlowBroadStrength, { max: 2, min: 0 }),
203
+ sunGlowCoreStrength: finiteNumber(source.sunGlowCoreStrength, base.sunGlowCoreStrength, { max: 2, min: 0 }),
204
+ sunCloudOcclusionStrength: finiteNumber(source.sunCloudOcclusionStrength, base.sunCloudOcclusionStrength, { max: 1, min: 0 }),
205
+ horizonScattering: finiteNumber(source.horizonScattering, base.horizonScattering, { max: 1, min: 0 }),
206
+ zenithExponent: finiteNumber(source.zenithExponent, base.zenithExponent, { max: 4, min: 0.1 }),
207
+ groundExponent: finiteNumber(source.groundExponent, base.groundExponent, { max: 4, min: 0.1 }),
208
+ horizonBandSize: finiteNumber(source.horizonBandSize, base.horizonBandSize, { max: 1, min: 0.02 }),
209
+ horizonSunPower: finiteNumber(source.horizonSunPower, base.horizonSunPower, { max: 20, min: 0.5 }),
210
+ cloudCoverage: finiteNumber(source.cloudCoverage, base.cloudCoverage, { max: 1, min: 0 }),
211
+ cloudScale: finiteNumber(source.cloudScale, base.cloudScale, { max: 6, min: 0.1 }),
212
+ cloudSpeed: finiteNumber(source.cloudSpeed, base.cloudSpeed, { max: 4, min: 0 }),
213
+ cloudDirection: direction2Array(source.cloudDirection, base.cloudDirection),
214
+ cloudSeed: Math.round(finiteNumber(source.cloudSeed, base.cloudSeed, { max: 1000, min: 0 })),
215
+ cloudProjection: finiteNumber(source.cloudProjection, base.cloudProjection, { max: 0.8, min: 0.05 }),
216
+ cloudSoftness: finiteNumber(source.cloudSoftness, base.cloudSoftness, { max: 0.3, min: 0.01 }),
217
+ cloudEdgeOpacity: finiteNumber(source.cloudEdgeOpacity, base.cloudEdgeOpacity, { max: 1, min: 0 }),
218
+ cloudOpacity: finiteNumber(source.cloudOpacity, base.cloudOpacity, { max: 1, min: 0 }),
219
+ cloudShadeStrength: finiteNumber(source.cloudShadeStrength, base.cloudShadeStrength, { max: 1, min: 0 }),
220
+ cloudShadeThreshold: finiteNumber(source.cloudShadeThreshold, base.cloudShadeThreshold, { max: 0.3, min: -0.3 }),
221
+ cloudShadeSoftness: finiteNumber(source.cloudShadeSoftness, base.cloudShadeSoftness, { max: 0.3, min: 0.001 }),
222
+ cloudLightOffset: finiteNumber(source.cloudLightOffset, base.cloudLightOffset, { max: 2, min: 0 }),
223
+ cloudSilverLiningStrength: finiteNumber(source.cloudSilverLiningStrength, base.cloudSilverLiningStrength, { max: 2, min: 0 }),
224
+ cloudSunPower: finiteNumber(source.cloudSunPower, base.cloudSunPower, { max: 40, min: 1 }),
225
+ cloudHorizonFade: finiteNumber(source.cloudHorizonFade, base.cloudHorizonFade, { max: 0.8, min: 0.02 }),
142
226
  cloudColor: colorArray(source.cloudColor, base.cloudColor),
143
227
  cloudShadeColor: colorArray(source.cloudShadeColor, base.cloudShadeColor),
144
- starsStrength: finiteNumber(source.starsStrength, base.starsStrength),
228
+ starsStrength: finiteNumber(source.starsStrength, base.starsStrength, { max: 2, min: 0 }),
229
+ starsColor: colorArray(source.starsColor, base.starsColor),
230
+ starsSeed: Math.round(finiteNumber(source.starsSeed, base.starsSeed, { max: 1000, min: 0 })),
231
+ starsScale: finiteNumber(source.starsScale, base.starsScale, { max: 64, min: 2 }),
232
+ starsDensity: finiteNumber(source.starsDensity, base.starsDensity, { max: 1, min: 0 }),
233
+ starsSize: finiteNumber(source.starsSize, base.starsSize, { max: 0.2, min: 0.005 }),
234
+ starsTwinkleStrength: finiteNumber(source.starsTwinkleStrength, base.starsTwinkleStrength, { max: 1, min: 0 }),
235
+ starsTwinkleSpeed: finiteNumber(source.starsTwinkleSpeed, base.starsTwinkleSpeed, { max: 4, min: 0 }),
236
+ starsHorizonFade: finiteNumber(source.starsHorizonFade, base.starsHorizonFade, { max: 1, min: 0.04 }),
145
237
  };
146
238
  }
147
239
 
@@ -184,6 +276,7 @@ const SKY_FIELD_DEFINITIONS = Object.freeze({
184
276
  description: 'Sphere radius of the sky dome in meters. Construction-only: baked into the dome geometry; applySettings stores but does not rebuild it.',
185
277
  label: 'Radius',
186
278
  range: { max: 1000, min: 10, step: 1 },
279
+ serializable: false,
187
280
  type: 'number',
188
281
  },
189
282
  },
@@ -203,9 +296,33 @@ const SKY_FIELD_DEFINITIONS = Object.freeze({
203
296
  label: 'Ground Color',
204
297
  type: 'color',
205
298
  },
299
+ zenithExponent: {
300
+ description: 'Shape of the horizon-to-zenith gradient. Lower values bring the zenith color farther toward the horizon.',
301
+ label: 'Zenith Gradient Shape',
302
+ range: { max: 4, min: 0.1, step: 0.01 },
303
+ type: 'number',
304
+ },
305
+ groundExponent: {
306
+ description: 'Shape of the mirrored below-horizon fade into the ground color.',
307
+ label: 'Ground Gradient Shape',
308
+ range: { max: 4, min: 0.1, step: 0.01 },
309
+ type: 'number',
310
+ },
311
+ horizonBandSize: {
312
+ description: 'Vertical size of the sun-side atmospheric scattering band around the horizon.',
313
+ label: 'Horizon Band Size',
314
+ range: { max: 1, min: 0.02, step: 0.01 },
315
+ type: 'number',
316
+ },
317
+ horizonSunPower: {
318
+ description: 'How tightly horizon scattering concentrates toward the sun direction.',
319
+ label: 'Horizon Sun Focus',
320
+ range: { max: 20, min: 0.5, step: 0.1 },
321
+ type: 'number',
322
+ },
206
323
  horizonScattering: {
207
- description: 'How far the bright horizon band bleeds up into the sky.',
208
- label: 'Horizon Scattering',
324
+ description: 'Strength of the bright sun-side atmospheric wedge at the horizon.',
325
+ label: 'Horizon Scatter Strength',
209
326
  range: { max: 1, min: 0, step: 0.01 },
210
327
  type: 'number',
211
328
  },
@@ -227,12 +344,54 @@ const SKY_FIELD_DEFINITIONS = Object.freeze({
227
344
  range: { max: 0.2, min: 0, step: 0.001 },
228
345
  type: 'number',
229
346
  },
347
+ sunDiscSoftness: {
348
+ description: 'Fraction of the disc radius used for its anti-aliased painterly edge.',
349
+ label: 'Disc Edge Softness',
350
+ range: { max: 1, min: 0.01, step: 0.01 },
351
+ type: 'number',
352
+ },
353
+ sunDiscIntensity: {
354
+ description: 'Brightness multiplier of the solid sun disc before the renderer tone map.',
355
+ label: 'Disc Intensity',
356
+ range: { max: 8, min: 0, step: 0.05 },
357
+ type: 'number',
358
+ },
230
359
  sunGlowStrength: {
231
- description: 'Intensity of the soft glow halo around the sun disc.',
232
- label: 'Sun Glow Strength',
360
+ description: 'Master intensity of the broad and core sun glow terms.',
361
+ label: 'Glow Strength',
233
362
  range: { max: 4, min: 0, step: 0.01 },
234
363
  type: 'number',
235
364
  },
365
+ sunGlowSpread: {
366
+ description: 'Falloff power of the broad halo. Lower values spread the glow across more sky.',
367
+ label: 'Glow Spread',
368
+ range: { max: 20, min: 1, step: 0.1 },
369
+ type: 'number',
370
+ },
371
+ sunGlowCoreSharpness: {
372
+ description: 'Falloff power of the tight inner halo. Higher values make a smaller, sharper core.',
373
+ label: 'Core Sharpness',
374
+ range: { max: 200, min: 5, step: 1 },
375
+ type: 'number',
376
+ },
377
+ sunGlowBroadStrength: {
378
+ description: 'Contribution of the broad halo inside the master glow strength.',
379
+ label: 'Broad Halo',
380
+ range: { max: 2, min: 0, step: 0.01 },
381
+ type: 'number',
382
+ },
383
+ sunGlowCoreStrength: {
384
+ description: 'Contribution of the tight inner halo inside the master glow strength.',
385
+ label: 'Core Halo',
386
+ range: { max: 2, min: 0, step: 0.01 },
387
+ type: 'number',
388
+ },
389
+ sunCloudOcclusionStrength: {
390
+ description: 'How strongly dense cloud coverage hides the sun disc. 0 keeps the disc visible through cloud.',
391
+ label: 'Cloud Occlusion',
392
+ range: { max: 1, min: 0, step: 0.01 },
393
+ type: 'number',
394
+ },
236
395
  },
237
396
  clouds: {
238
397
  cloudCoverage: {
@@ -247,12 +406,48 @@ const SKY_FIELD_DEFINITIONS = Object.freeze({
247
406
  range: { max: 6, min: 0.1, step: 0.01 },
248
407
  type: 'number',
249
408
  },
409
+ cloudSoftness: {
410
+ description: 'Width of the painterly cloud silhouette transition.',
411
+ label: 'Edge Softness',
412
+ range: { max: 0.3, min: 0.01, step: 0.005 },
413
+ type: 'number',
414
+ },
415
+ cloudProjection: {
416
+ description: 'Perspective offset of the virtual cloud plane; higher values flatten clouds toward the horizon.',
417
+ label: 'Layer Projection',
418
+ range: { max: 0.8, min: 0.05, step: 0.01 },
419
+ type: 'number',
420
+ },
421
+ cloudOpacity: {
422
+ description: 'Overall blend opacity of the procedural cloud layer.',
423
+ label: 'Cloud Opacity',
424
+ range: { max: 1, min: 0, step: 0.01 },
425
+ type: 'number',
426
+ },
427
+ cloudEdgeOpacity: {
428
+ description: 'Opacity of the soft outer silhouette relative to the solid cloud core.',
429
+ label: 'Edge Opacity',
430
+ range: { max: 1, min: 0, step: 0.01 },
431
+ type: 'number',
432
+ },
250
433
  cloudSpeed: {
251
- description: 'How fast clouds drift across the dome.',
434
+ description: 'How fast the authored cloud layer drifts across the dome.',
252
435
  label: 'Cloud Speed',
253
436
  range: { max: 4, min: 0, step: 0.01 },
254
437
  type: 'number',
255
438
  },
439
+ cloudDirection: {
440
+ description: 'Normalized horizontal drift direction of the authored cloud layer; speed is controlled separately.',
441
+ label: 'Cloud Direction',
442
+ type: 'vector2',
443
+ },
444
+ cloudSeed: {
445
+ description: 'Offsets the procedural cloud field to produce a different deterministic composition.',
446
+ integer: true,
447
+ label: 'Cloud Pattern Seed',
448
+ range: { max: 1000, min: 0, step: 1 },
449
+ type: 'number',
450
+ },
256
451
  cloudColor: {
257
452
  description: 'Lit tone of the two-tone painterly clouds.',
258
453
  label: 'Cloud Color',
@@ -263,6 +458,48 @@ const SKY_FIELD_DEFINITIONS = Object.freeze({
263
458
  label: 'Cloud Shade Color',
264
459
  type: 'color',
265
460
  },
461
+ cloudShadeStrength: {
462
+ description: 'Strength of the two-tone shaded underside.',
463
+ label: 'Shade Strength',
464
+ range: { max: 1, min: 0, step: 0.01 },
465
+ type: 'number',
466
+ },
467
+ cloudShadeThreshold: {
468
+ description: 'Noise-difference threshold that separates the lit and shaded cloud tones.',
469
+ label: 'Shade Threshold',
470
+ range: { max: 0.3, min: -0.3, step: 0.005 },
471
+ type: 'number',
472
+ },
473
+ cloudShadeSoftness: {
474
+ description: 'Softness of the transition between the two cloud tones.',
475
+ label: 'Shade Softness',
476
+ range: { max: 0.3, min: 0.001, step: 0.005 },
477
+ type: 'number',
478
+ },
479
+ cloudLightOffset: {
480
+ description: 'Distance of the secondary noise sample toward the sun; controls the depth and directionality of cloud shading.',
481
+ label: 'Light Sample Offset',
482
+ range: { max: 2, min: 0, step: 0.01 },
483
+ type: 'number',
484
+ },
485
+ cloudSilverLiningStrength: {
486
+ description: 'Warm sun-colored lining added to cloud edges facing the sun.',
487
+ label: 'Silver Lining',
488
+ range: { max: 2, min: 0, step: 0.01 },
489
+ type: 'number',
490
+ },
491
+ cloudSunPower: {
492
+ description: 'Angular focus of the sun-colored cloud lining.',
493
+ label: 'Lining Focus',
494
+ range: { max: 40, min: 1, step: 0.5 },
495
+ type: 'number',
496
+ },
497
+ cloudHorizonFade: {
498
+ description: 'Altitude at which the cloud layer reaches full opacity above the horizon.',
499
+ label: 'Horizon Fade',
500
+ range: { max: 0.8, min: 0.02, step: 0.01 },
501
+ type: 'number',
502
+ },
266
503
  },
267
504
  stars: {
268
505
  starsStrength: {
@@ -271,6 +508,54 @@ const SKY_FIELD_DEFINITIONS = Object.freeze({
271
508
  range: { max: 2, min: 0, step: 0.01 },
272
509
  type: 'number',
273
510
  },
511
+ starsColor: {
512
+ description: 'Tint of the procedural star glints.',
513
+ label: 'Stars Color',
514
+ type: 'color',
515
+ },
516
+ starsSeed: {
517
+ description: 'Offsets the deterministic star pattern without changing density or size.',
518
+ integer: true,
519
+ label: 'Stars Pattern Seed',
520
+ range: { max: 1000, min: 0, step: 1 },
521
+ type: 'number',
522
+ },
523
+ starsDensity: {
524
+ description: 'Fraction of candidate cells allowed to contain a visible star.',
525
+ label: 'Stars Density',
526
+ range: { max: 1, min: 0, step: 0.01 },
527
+ type: 'number',
528
+ },
529
+ starsScale: {
530
+ description: 'Density scale of the projected star grid; higher values produce more, smaller cells.',
531
+ label: 'Stars Scale',
532
+ range: { max: 64, min: 2, step: 0.5 },
533
+ type: 'number',
534
+ },
535
+ starsSize: {
536
+ description: 'Size of each procedural star glint inside its cell.',
537
+ label: 'Stars Size',
538
+ range: { max: 0.2, min: 0.005, step: 0.005 },
539
+ type: 'number',
540
+ },
541
+ starsTwinkleStrength: {
542
+ description: 'Depth of per-star brightness animation. 0 disables twinkle without hiding stars.',
543
+ label: 'Twinkle Strength',
544
+ range: { max: 1, min: 0, step: 0.01 },
545
+ type: 'number',
546
+ },
547
+ starsTwinkleSpeed: {
548
+ description: 'Speed multiplier of the seeded per-star twinkle animation.',
549
+ label: 'Twinkle Speed',
550
+ range: { max: 4, min: 0, step: 0.01 },
551
+ type: 'number',
552
+ },
553
+ starsHorizonFade: {
554
+ description: 'Altitude at which the star field reaches full brightness above the horizon.',
555
+ label: 'Horizon Fade',
556
+ range: { max: 1, min: 0.04, step: 0.01 },
557
+ type: 'number',
558
+ },
274
559
  },
275
560
  });
276
561
 
@@ -281,6 +566,7 @@ function createSkyFieldMetadata(group, key, field) {
281
566
  description: field.description,
282
567
  group: group.id,
283
568
  id: `${group.id}.${key}`,
569
+ integer: field.integer ?? false,
284
570
  key,
285
571
  label: field.label,
286
572
  optionLabels: field.optionLabels ?? null,
@@ -310,6 +596,239 @@ export const SKY_SETTING_FIELD_SCHEMA = Object.freeze(
310
596
  ),
311
597
  );
312
598
 
599
+ const SKY_FIELDS_BY_KEY = Object.freeze(Object.fromEntries(
600
+ Object.values(SKY_SETTING_FIELD_SCHEMA)
601
+ .flatMap((fields) => Object.entries(fields)),
602
+ ));
603
+
604
+ function cleanSkyObject(value) {
605
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
606
+ }
607
+
608
+ function normalizeSkyPresetId(value) {
609
+ return String(value ?? '').trim();
610
+ }
611
+
612
+ function collectTopLevelSkySettings(source) {
613
+ const input = cleanSkyObject(source);
614
+ return Object.fromEntries(
615
+ Object.keys(SKY_FIELDS_BY_KEY)
616
+ .filter((key) => input[key] !== undefined)
617
+ .map((key) => [key, input[key]]),
618
+ );
619
+ }
620
+
621
+ function collectSkyPresetWarnings(settings = {}) {
622
+ const warnings = [];
623
+ for (const key of Object.keys(cleanSkyObject(settings))) {
624
+ const field = SKY_FIELDS_BY_KEY[key];
625
+ if (!field) warnings.push(`Unknown sky setting "${key}" was ignored.`);
626
+ else if (!field.serializable) {
627
+ warnings.push(`Sky setting "${key}" is construction-only and was not stored in the preset.`);
628
+ }
629
+ }
630
+ return warnings;
631
+ }
632
+
633
+ /**
634
+ * Normalizes a sky look into a complete, JSON-safe settings object. Runtime
635
+ * construction state such as the dome radius is intentionally excluded.
636
+ */
637
+ export function sanitizeSkyPresetSettings(settings = {}) {
638
+ const knownSettings = collectTopLevelSkySettings(settings);
639
+ const normalized = createSkySettings(knownSettings);
640
+ return Object.fromEntries(
641
+ Object.entries(SKY_FIELDS_BY_KEY)
642
+ .filter(([, field]) => field.serializable)
643
+ .map(([key]) => [key, normalized[key]]),
644
+ );
645
+ }
646
+
647
+ function migrateSkyPresetDocument(input) {
648
+ const source = cleanSkyObject(input);
649
+ const numericVersion = Number(source.version ?? source.schemaVersion ?? 0);
650
+ const version = Number.isFinite(numericVersion) ? Math.round(numericVersion) : 0;
651
+ if (version > SKY_PRESET_SCHEMA_VERSION) return { ...source, version };
652
+ const nestedSettings = cleanSkyObject(source.settings);
653
+ return {
654
+ description: source.description ?? '',
655
+ id: source.id ?? source.name ?? source.preset ?? '',
656
+ label: source.label ?? source.title ?? source.name ?? source.id ?? '',
657
+ settings: Object.keys(nestedSettings).length > 0
658
+ ? nestedSettings
659
+ : collectTopLevelSkySettings(source),
660
+ type: source.type ?? SKY_PRESET_DOCUMENT_TYPE,
661
+ version: SKY_PRESET_SCHEMA_VERSION,
662
+ };
663
+ }
664
+
665
+ /** Validates and normalizes a portable sky preset document. Never throws. */
666
+ export function validateSkyPresetDocument(input) {
667
+ if (cleanSkyObject(input).type !== undefined && input.type !== SKY_PRESET_DOCUMENT_TYPE) {
668
+ return {
669
+ errors: [`Sky preset type must be "${SKY_PRESET_DOCUMENT_TYPE}".`],
670
+ ok: false,
671
+ value: null,
672
+ warnings: [],
673
+ };
674
+ }
675
+ return validateSettingsPresetDocument(input, {
676
+ collectWarnings: collectSkyPresetWarnings,
677
+ documentType: SKY_PRESET_DOCUMENT_TYPE,
678
+ migrateDocument: migrateSkyPresetDocument,
679
+ normalizeId: normalizeSkyPresetId,
680
+ sanitizeSettings: sanitizeSkyPresetSettings,
681
+ schemaVersion: SKY_PRESET_SCHEMA_VERSION,
682
+ });
683
+ }
684
+
685
+ /** Parses JSON text or an object into a validated sky preset document. */
686
+ export function parseSkyPresetDocument(input) {
687
+ return parsePresetDocument(input, validateSkyPresetDocument, {
688
+ invalidJsonLabel: 'sky preset',
689
+ });
690
+ }
691
+
692
+ /** Creates a canonical, versioned sky preset document. */
693
+ export function createSkyPresetDocument(id, definition = {}) {
694
+ return createSettingsPresetDocument(id, definition, {
695
+ collectSettings: (source) => source.settings ?? collectTopLevelSkySettings(source),
696
+ documentType: SKY_PRESET_DOCUMENT_TYPE,
697
+ schemaVersion: SKY_PRESET_SCHEMA_VERSION,
698
+ validateDocument: validateSkyPresetDocument,
699
+ });
700
+ }
701
+
702
+ /** Serializes a sky preset id/definition or document-like object as JSON. */
703
+ export function serializeSkyPreset(idOrDocument, definition = {}, { pretty = true } = {}) {
704
+ return serializePresetDocument(idOrDocument, definition, {
705
+ argumentCount: arguments.length,
706
+ createDocument: createSkyPresetDocument,
707
+ pretty,
708
+ });
709
+ }
710
+
711
+ /** Registers a portable preset document, overwriting an existing id by default. */
712
+ export function registerSerializedSkyPreset(input, options = {}) {
713
+ const result = parseSkyPresetDocument(input);
714
+ if (!result.ok) throw new Error(result.errors.join(' '));
715
+ return registerSkyPreset(result.value.id, result.value, {
716
+ overwrite: options.overwrite ?? true,
717
+ });
718
+ }
719
+
720
+ // Keep the historical ids stable, then provide distinct looks that are useful
721
+ // as authoring starting points rather than aliases for the same daytime sky.
722
+ registerSkyPreset('default', {
723
+ description: 'Baseline stylized daytime sky.',
724
+ label: 'Default',
725
+ });
726
+
727
+ registerSkyPreset('call_me_sensei', {
728
+ description: 'Studio-managed signature sky, curated by Call Me Sensei and updated over releases.',
729
+ label: 'Call Me Sensei',
730
+ settings: {
731
+ cloudCoverage: 0.36,
732
+ cloudScale: 1.45,
733
+ cloudSeed: 7,
734
+ cloudSoftness: 0.12,
735
+ horizonScattering: 0.56,
736
+ sunGlowStrength: 1.1,
737
+ zenithColor: [0.24, 0.52, 0.92],
738
+ },
739
+ });
740
+
741
+ registerSkyPreset('clear_day', {
742
+ description: 'Crisp blue daylight with sparse, slow-moving clouds.',
743
+ label: 'Clear Day',
744
+ settings: {
745
+ cloudCoverage: 0.12,
746
+ cloudEdgeOpacity: 0.48,
747
+ cloudScale: 1.9,
748
+ cloudSeed: 12,
749
+ cloudSoftness: 0.075,
750
+ cloudSpeed: 0.65,
751
+ horizonColor: [0.76, 0.93, 1.0],
752
+ horizonScattering: 0.42,
753
+ sunGlowStrength: 1.15,
754
+ zenithColor: [0.2, 0.52, 0.95],
755
+ },
756
+ });
757
+
758
+ registerSkyPreset('golden_hour', {
759
+ description: 'Low warm sun, peach horizon, and softly lit evening clouds.',
760
+ label: 'Golden Hour',
761
+ settings: {
762
+ cloudColor: [1.0, 0.78, 0.6],
763
+ cloudCoverage: 0.34,
764
+ cloudProjection: 0.28,
765
+ cloudSeed: 47,
766
+ cloudShadeColor: [0.58, 0.38, 0.52],
767
+ cloudSilverLiningStrength: 0.62,
768
+ groundColor: [0.3, 0.22, 0.28],
769
+ horizonColor: [1.0, 0.55, 0.28],
770
+ horizonScattering: 0.76,
771
+ starsStrength: 0.04,
772
+ sunColor: [1.0, 0.62, 0.3],
773
+ sunDirection: [0.76, 0.18, 0.36],
774
+ sunGlowStrength: 1.85,
775
+ sunGlowSpread: 3.8,
776
+ sunDiscIntensity: 3.0,
777
+ sunSize: 0.036,
778
+ zenithColor: [0.22, 0.36, 0.68],
779
+ },
780
+ });
781
+
782
+ registerSkyPreset('overcast', {
783
+ description: 'Dense cool cloud cover with broad, low-contrast daylight.',
784
+ label: 'Overcast',
785
+ settings: {
786
+ cloudColor: [0.78, 0.83, 0.9],
787
+ cloudCoverage: 0.88,
788
+ cloudEdgeOpacity: 0.82,
789
+ cloudScale: 1.2,
790
+ cloudSeed: 88,
791
+ cloudShadeColor: [0.42, 0.5, 0.62],
792
+ cloudShadeSoftness: 0.1,
793
+ cloudSoftness: 0.17,
794
+ cloudSpeed: 0.72,
795
+ groundColor: [0.3, 0.35, 0.42],
796
+ horizonColor: [0.62, 0.7, 0.78],
797
+ horizonScattering: 0.72,
798
+ sunColor: [0.82, 0.88, 1.0],
799
+ sunGlowStrength: 0.16,
800
+ sunSize: 0.018,
801
+ zenithColor: [0.38, 0.49, 0.62],
802
+ },
803
+ });
804
+
805
+ registerSkyPreset('moonlit', {
806
+ description: 'Deep blue night with a cool moon glow, quiet clouds, and bright stars.',
807
+ label: 'Moonlit Night',
808
+ settings: {
809
+ cloudColor: [0.2, 0.27, 0.42],
810
+ cloudCoverage: 0.28,
811
+ cloudSeed: 31,
812
+ cloudShadeColor: [0.06, 0.08, 0.17],
813
+ cloudSpeed: 0.35,
814
+ groundColor: [0.015, 0.02, 0.05],
815
+ horizonColor: [0.09, 0.14, 0.25],
816
+ horizonScattering: 0.25,
817
+ starsStrength: 1.1,
818
+ starsColor: [0.72, 0.82, 1.0],
819
+ starsDensity: 0.42,
820
+ starsScale: 18,
821
+ starsSeed: 173,
822
+ starsSize: 0.045,
823
+ starsTwinkleStrength: 0.9,
824
+ sunColor: [0.58, 0.7, 1.0],
825
+ sunDirection: [-0.45, 0.6, 0.3],
826
+ sunGlowStrength: 0.38,
827
+ sunSize: 0.018,
828
+ zenithColor: [0.015, 0.035, 0.11],
829
+ },
830
+ });
831
+
313
832
  function setSrgbColorUniform(uniform, rgb) {
314
833
  uniform.value.setRGB(rgb[0], rgb[1], rgb[2], THREE.SRGBColorSpace);
315
834
  }
@@ -319,27 +838,66 @@ export function applySkySettingsToMaterial(material, options = {}) {
319
838
  // natively, the TSL factory via same-name UniformNodes (`.value` on both).
320
839
  const uniforms = material?.uniforms;
321
840
  if (!uniforms) return material;
322
- const settings = createSkySettings(options);
841
+ const source = cleanSkyObject(options);
842
+ const settings = createSkySettings({
843
+ ...(source.preset === undefined ? cleanSkyObject(material.userData?.skySettings) : {}),
844
+ ...source,
845
+ });
323
846
  setSrgbColorUniform(uniforms.uZenithColor, settings.zenithColor);
324
847
  setSrgbColorUniform(uniforms.uHorizonColor, settings.horizonColor);
325
848
  setSrgbColorUniform(uniforms.uGroundColor, settings.groundColor);
849
+ uniforms.uZenithExponent.value = settings.zenithExponent;
850
+ uniforms.uGroundExponent.value = settings.groundExponent;
851
+ uniforms.uHorizonBandSize.value = settings.horizonBandSize;
852
+ uniforms.uHorizonSunPower.value = settings.horizonSunPower;
326
853
  uniforms.uSunDirection.value.set(...settings.sunDirection).normalize();
327
854
  setSrgbColorUniform(uniforms.uSunColor, settings.sunColor);
328
855
  uniforms.uSunSize.value = settings.sunSize;
856
+ uniforms.uSunDiscSoftness.value = settings.sunDiscSoftness;
857
+ uniforms.uSunDiscIntensity.value = settings.sunDiscIntensity;
329
858
  uniforms.uSunGlowStrength.value = settings.sunGlowStrength;
859
+ uniforms.uSunGlowSpread.value = settings.sunGlowSpread;
860
+ uniforms.uSunGlowCoreSharpness.value = settings.sunGlowCoreSharpness;
861
+ uniforms.uSunGlowBroadStrength.value = settings.sunGlowBroadStrength;
862
+ uniforms.uSunGlowCoreStrength.value = settings.sunGlowCoreStrength;
863
+ uniforms.uSunCloudOcclusionStrength.value = settings.sunCloudOcclusionStrength;
330
864
  uniforms.uHorizonScattering.value = settings.horizonScattering;
331
865
  uniforms.uCloudCoverage.value = settings.cloudCoverage;
332
866
  uniforms.uCloudScale.value = settings.cloudScale;
333
867
  uniforms.uCloudSpeed.value = settings.cloudSpeed;
868
+ uniforms.uCloudDirection.value.set(...settings.cloudDirection);
869
+ uniforms.uCloudSeed.value = settings.cloudSeed;
870
+ uniforms.uCloudProjection.value = settings.cloudProjection;
871
+ uniforms.uCloudSoftness.value = settings.cloudSoftness;
872
+ uniforms.uCloudEdgeOpacity.value = settings.cloudEdgeOpacity;
873
+ uniforms.uCloudOpacity.value = settings.cloudOpacity;
874
+ uniforms.uCloudShadeStrength.value = settings.cloudShadeStrength;
875
+ uniforms.uCloudShadeThreshold.value = settings.cloudShadeThreshold;
876
+ uniforms.uCloudShadeSoftness.value = settings.cloudShadeSoftness;
877
+ uniforms.uCloudLightOffset.value = settings.cloudLightOffset;
878
+ uniforms.uCloudSilverLiningStrength.value = settings.cloudSilverLiningStrength;
879
+ uniforms.uCloudSunPower.value = settings.cloudSunPower;
880
+ uniforms.uCloudHorizonFade.value = settings.cloudHorizonFade;
334
881
  setSrgbColorUniform(uniforms.uCloudColor, settings.cloudColor);
335
882
  setSrgbColorUniform(uniforms.uCloudShadeColor, settings.cloudShadeColor);
336
883
  uniforms.uStarsStrength.value = settings.starsStrength;
884
+ setSrgbColorUniform(uniforms.uStarsColor, settings.starsColor);
885
+ uniforms.uStarsSeed.value = settings.starsSeed;
886
+ uniforms.uStarsScale.value = settings.starsScale;
887
+ uniforms.uStarsDensity.value = settings.starsDensity;
888
+ uniforms.uStarsSize.value = settings.starsSize;
889
+ uniforms.uStarsTwinkleStrength.value = settings.starsTwinkleStrength;
890
+ uniforms.uStarsTwinkleSpeed.value = settings.starsTwinkleSpeed;
891
+ uniforms.uStarsHorizonFade.value = settings.starsHorizonFade;
337
892
  material.userData.skySettings = settings;
338
893
  return material;
339
894
  }
340
895
 
341
896
  export function createSkyMaterial(options = {}) {
342
- return applySkySettingsToMaterial(createSkyNodeMaterial(), options);
897
+ return applySkySettingsToMaterial(
898
+ createSkyNodeMaterial({ quality: options?.quality }),
899
+ options,
900
+ );
343
901
  }
344
902
 
345
903
  export class StylizedSky extends THREE.Mesh {
@@ -350,16 +908,75 @@ export class StylizedSky extends THREE.Mesh {
350
908
  */
351
909
  constructor(options = {}) {
352
910
  const settings = createSkySettings(options);
353
- super(new THREE.SphereGeometry(settings.radius, 48, 24), createSkyMaterial(settings));
911
+ const quality = resolveSkyQuality(options?.quality);
912
+ super(
913
+ new THREE.SphereGeometry(settings.radius, 48, 24),
914
+ createSkyMaterial({ ...settings, quality }),
915
+ );
354
916
  this.name = 'StylizedSky';
355
917
  this.frustumCulled = false;
356
918
  this.renderOrder = -100;
919
+ this._authoredSettings = settings;
920
+ this._quality = quality;
921
+ this._retiredMaterials = [];
922
+ this._sceneOverrideLayers = new Map();
923
+ this._sceneOverrideSequence = 0;
924
+ this._sceneOverrides = {};
357
925
  }
358
926
 
359
927
  get settings() {
928
+ return this._authoredSettings;
929
+ }
930
+
931
+ /** Current compile-time deployment tier; not part of the authored preset. */
932
+ get quality() {
933
+ return this._quality;
934
+ }
935
+
936
+ /** The settings currently uploaded after transient scene overrides. */
937
+ get renderedSettings() {
360
938
  return this.material.userData.skySettings;
361
939
  }
362
940
 
941
+ /** Current transient scene overrides, kept separate from authored settings. */
942
+ get sceneOverrides() {
943
+ return structuredClone(this._sceneOverrides);
944
+ }
945
+
946
+ /** Ordered runtime layer metadata, without exposing mutable resolvers. */
947
+ get sceneOverrideLayers() {
948
+ return [...this._sceneOverrideLayers.values()]
949
+ .sort((a, b) => a.priority - b.priority || a.order - b.order)
950
+ .map((layer) => ({ id: layer.id, priority: layer.priority }));
951
+ }
952
+
953
+ _composeSceneSettings() {
954
+ let composed = createSkySettings(this._authoredSettings);
955
+ const layers = [...this._sceneOverrideLayers.values()]
956
+ .sort((a, b) => a.priority - b.priority || a.order - b.order);
957
+ for (const layer of layers) {
958
+ const source = layer.resolve
959
+ ? layer.resolve(structuredClone(composed))
960
+ : layer.settings;
961
+ const next = collectTopLevelSkySettings(source);
962
+ delete next.radius;
963
+ composed = createSkySettings({ ...composed, ...next });
964
+ }
965
+ return composed;
966
+ }
967
+
968
+ _applyComposedSceneSettings() {
969
+ const composed = this._composeSceneSettings();
970
+ this._sceneOverrides = Object.fromEntries(
971
+ Object.keys(SKY_FIELDS_BY_KEY)
972
+ .filter((key) => key !== 'radius')
973
+ .filter((key) => JSON.stringify(composed[key]) !== JSON.stringify(this._authoredSettings[key]))
974
+ .map((key) => [key, composed[key]]),
975
+ );
976
+ applySkySettingsToMaterial(this.material, composed);
977
+ return this.renderedSettings;
978
+ }
979
+
363
980
  /**
364
981
  * Runtime re-tune: merges `options` into the current settings and pushes
365
982
  * every value into the material uniforms. `radius` is construction-only
@@ -371,10 +988,99 @@ export class StylizedSky extends THREE.Mesh {
371
988
  * @returns {Object} The updated settings object.
372
989
  */
373
990
  applySettings(options = {}) {
374
- applySkySettingsToMaterial(this.material, { ...this.settings, ...options });
991
+ if (cleanSkyObject(options).preset !== undefined) {
992
+ const { preset, ...overrides } = options;
993
+ return this.setPreset(preset, overrides);
994
+ }
995
+ this._authoredSettings = createSkySettings({ ...this._authoredSettings, ...options });
996
+ this._applyComposedSceneSettings();
375
997
  return this.settings;
376
998
  }
377
999
 
1000
+ /** Replaces the authored look from a registered preset, then recomposes runtime layers. */
1001
+ setPreset(name, overrides = {}) {
1002
+ this._authoredSettings = createSkySettings({ preset: name, ...cleanSkyObject(overrides) });
1003
+ this._applyComposedSceneSettings();
1004
+ return this.settings;
1005
+ }
1006
+
1007
+ /**
1008
+ * Adds or replaces one transient world-state layer. A resolver receives the
1009
+ * result of all lower-priority layers, which lets Weather tint the current
1010
+ * Lighting time-of-day instead of competing with it.
1011
+ */
1012
+ setSceneOverrideLayer(id, optionsOrResolver = {}, {
1013
+ priority = SKY_SCENE_OVERRIDE_PRIORITIES.scene,
1014
+ replace = true,
1015
+ } = {}) {
1016
+ if ((typeof id !== 'string' || id.length === 0) && typeof id !== 'symbol') {
1017
+ throw new TypeError('A sky scene override layer needs a non-empty string or Symbol id.');
1018
+ }
1019
+ const existing = this._sceneOverrideLayers.get(id);
1020
+ const resolve = typeof optionsOrResolver === 'function' ? optionsOrResolver : null;
1021
+ let settings = null;
1022
+ if (!resolve) {
1023
+ const next = collectTopLevelSkySettings(optionsOrResolver);
1024
+ delete next.radius;
1025
+ settings = !replace && existing?.settings
1026
+ ? { ...existing.settings, ...next }
1027
+ : next;
1028
+ }
1029
+ this._sceneOverrideLayers.set(id, {
1030
+ id,
1031
+ order: existing?.order ?? this._sceneOverrideSequence++,
1032
+ priority: Number.isFinite(Number(priority)) ? Number(priority) : SKY_SCENE_OVERRIDE_PRIORITIES.scene,
1033
+ resolve,
1034
+ settings,
1035
+ });
1036
+ return this._applyComposedSceneSettings();
1037
+ }
1038
+
1039
+ /** Removes one runtime owner without disturbing any other active layer. */
1040
+ clearSceneOverrideLayer(id) {
1041
+ if (!this._sceneOverrideLayers.delete(id)) return this.renderedSettings;
1042
+ return this._applyComposedSceneSettings();
1043
+ }
1044
+
1045
+ /**
1046
+ * Applies transient Lighting/Weather/world-state inputs without modifying
1047
+ * the authored sky preset returned by {@link settings}.
1048
+ */
1049
+ setSceneOverrides(options = {}, { replace = false } = {}) {
1050
+ return this.setSceneOverrideLayer('scene', options, {
1051
+ priority: SKY_SCENE_OVERRIDE_PRIORITIES.scene,
1052
+ replace,
1053
+ });
1054
+ }
1055
+
1056
+ /** Clears only the compatibility/manual `scene` layer. */
1057
+ clearSceneOverrides() {
1058
+ return this.clearSceneOverrideLayer('scene');
1059
+ }
1060
+
1061
+ /** Explicit full teardown for hosts that own every runtime Sky layer. */
1062
+ clearAllSceneOverrideLayers() {
1063
+ this._sceneOverrideLayers.clear();
1064
+ return this._applyComposedSceneSettings();
1065
+ }
1066
+
1067
+ /** Rebuilds only the material graph for a new deployment-quality tier. */
1068
+ setQuality(value) {
1069
+ const quality = resolveSkyQuality(value);
1070
+ if (quality.cloudOctaves === this._quality.cloudOctaves) return this;
1071
+ const previous = this.material;
1072
+ const elapsed = previous.uniforms?.uTime?.value ?? 0;
1073
+ this._quality = quality;
1074
+ this.material = createSkyMaterial({ ...this.renderedSettings, quality });
1075
+ this.material.uniforms.uTime.value = elapsed;
1076
+ // WebGPU submission is asynchronous. Disposing the old graph here can
1077
+ // destroy binding buffers still referenced by the command buffer already
1078
+ // in flight. Quality changes are rare deployment/lab actions, so retain
1079
+ // replaced graphs and release them with the Sky object at safe teardown.
1080
+ this._retiredMaterials.push(previous);
1081
+ return this;
1082
+ }
1083
+
378
1084
  // Advances cloud/star animation and keeps the dome centered on the camera.
379
1085
  update(delta, camera) {
380
1086
  this.material.uniforms.uTime.value += Math.min(Math.max(delta ?? 0.016, 0), 0.1);
@@ -385,5 +1091,7 @@ export class StylizedSky extends THREE.Mesh {
385
1091
  dispose() {
386
1092
  this.geometry.dispose();
387
1093
  this.material.dispose();
1094
+ for (const material of this._retiredMaterials) material.dispose();
1095
+ this._retiredMaterials.length = 0;
388
1096
  }
389
1097
  }