Compare commits

...

58 Commits

Author SHA1 Message Date
baf4215454 Improve type safety when parsing serialized scene documents in tests 2026-05-21 08:12:14 +02:00
572e1434e4 auto-git:
[change] src/app/App.tsx
 [change] src/document/migrate-scene-document.ts
 [change] src/document/scene-document-validation.ts
 [change] src/document/scene-document.ts
 [change] src/document/world-settings.ts
 [change] src/rendering/advanced-rendering.ts
 [change] tests/domain/advanced-rendering.test.ts
 [change] tests/serialization/scene-document-json.test.ts
2026-05-21 08:11:58 +02:00
69b13e9738 Enhance advanced rendering tests and add anti-aliasing validation to scene document validation 2026-05-21 08:11:20 +02:00
bbc2edc3d1 Add migration tests for anti-aliasing and advanced rendering features 2026-05-21 08:10:07 +02:00
02e8ca7432 Update expected version number for foliage wind scene document 2026-05-21 08:10:07 +02:00
93750e9b47 Test anti-aliasing settings in scene document JSON serialization 2026-05-21 08:10:07 +02:00
dd526475e4 Add anti-aliasing scene document version constant to tests 2026-05-21 08:10:07 +02:00
51a7de1068 Implement advanced rendering controls (Anti-Aliasing, Shadows) and update world settings equality tests 2026-05-21 08:09:34 +02:00
27f06c8b10 Add anti-aliasing mode labeling and application functions 2026-05-21 08:09:21 +02:00
8a5611ed4c Implement advanced anti-aliasing support (FXAA/SMAA) in rendering pipeline 2026-05-21 08:09:10 +02:00
c010602b45 Feat: Add anti-aliasing support to scene document 2026-05-21 08:08:55 +02:00
753f66e2e6 Feature: Implement anti-aliasing support in advanced rendering settings 2026-05-21 08:08:44 +02:00
0272f186bc Update test description for terrain LoD skirt vertices 2026-05-21 08:01:37 +02:00
77f1a19310 auto-git:
[change] src/geometry/terrain-mesh.ts
 [change] tests/geometry/terrain-mesh.test.ts
2026-05-21 07:59:27 +02:00
ee65a479e3 Move boundary sample skipping logic to lowered edge vertex collection 2026-05-21 07:59:17 +02:00
a60a963a00 Add boundary checks for top edge key collection 2026-05-21 07:59:17 +02:00
5a90ba88cc feat(tests): Add comprehensive tests for terrain LoD chunk boundary consistency and edge preservation 2026-05-21 07:58:22 +02:00
25df34d485 auto-git:
[change] src/geometry/terrain-mesh.ts
2026-05-21 07:58:01 +02:00
572d0ebf46 Improve null safety when retrieving foliage wind attribute in tests 2026-05-21 06:11:04 +02:00
375029afb0 Test: Ensure zero wind influence for prototypes with no authored wind strength 2026-05-21 06:09:37 +02:00
cc7a8ca768 Refactor wind direction calculation to use in-place vector writing 2026-05-21 06:09:28 +02:00
26e47fd275 Add comprehensive tests for wind simulation and material patching in foliage renderer 2026-05-21 06:09:09 +02:00
fe1bb3b9ce Feature: Implement wind simulation for foliage rendering across renderer, runtime, and viewport hosts 2026-05-21 06:08:39 +02:00
d0a33aa97d Feat: Implement wind simulation for instanced foliage rendering 2026-05-21 06:08:11 +02:00
f3979f1ba4 Add structure and types for wind simulation in foliage renderer 2026-05-21 06:07:28 +02:00
c7abf10930 Feat: Add foliage wind settings for advanced rendering
Implements controls and logic for managing foliage wind parameters (enablement, strength, speed, and direction) in the advanced rendering settings. Updates serialization tests to handle these new fields and migration from older document versions.
2026-05-21 06:07:12 +02:00
18ff8464d6 Feat(tests): Add validation and serialization support for foliage wind settings 2026-05-21 06:06:31 +02:00
1995257790 Add validation for foliage wind settings in world document validation 2026-05-21 06:05:20 +02:00
15884a6b92 Feature: Add support for wind parameters in foliage quality settings, updating defaults, resolution, and migration logic. 2026-05-21 06:04:55 +02:00
7f8247691f Add wind parameters and settings to foliage quality configuration 2026-05-21 06:04:20 +02:00
8e36b04aa7 Update test setup for scene document serialization/migration logic 2026-05-19 21:19:33 +02:00
2b711725a0 Add support for screen-space lens flare effects 2026-05-19 21:15:50 +02:00
4141907a70 Update scene migration to handle custom PBR materials document 2026-05-19 21:15:50 +02:00
33d0f2412e Add validation tests for advanced rendering lens flare properties 2026-05-19 21:14:27 +02:00
54b026eac0 Update scene document serialization test to use lens flare version constant 2026-05-19 21:14:27 +02:00
d09a826d19 Test migration of scene documents missing lens flare settings 2026-05-19 21:13:57 +02:00
4b32e3728d Add Lens Flare settings to scene document test data 2026-05-19 21:13:57 +02:00
62626d047d Add Lens Flare version constant to serialization tests 2026-05-19 21:13:57 +02:00
bf7a91326f Add tests for advanced rendering features, including lens flare logic 2026-05-19 21:13:32 +02:00
280d6449a3 Add comprehensive unit tests for screen-space lens flare parameters 2026-05-19 21:13:23 +02:00
1f3cc4af6c Add imports for screen-space lens flare rendering utilities to tests 2026-05-19 21:13:23 +02:00
18da770588 Feat: Add advanced rendering controls (Lens Flare, Volume Rendering Paths) 2026-05-19 21:12:48 +02:00
51eb35f199 Feature: Add advanced rendering controls for lens flare effects 2026-05-19 21:12:34 +02:00
2e58420d53 Add state management for lens flare rendering parameters 2026-05-19 21:12:25 +02:00
107ccbf640 Feat: Add screen-space lens flare support and refactor lighting calculation 2026-05-19 21:12:15 +02:00
beabde908d Feature: Implement screen-space lens flare light source and rendering 2026-05-19 21:12:00 +02:00
b371bc4ecd auto-git:
[change] src/rendering/advanced-rendering.ts
2026-05-19 21:11:46 +02:00
ad83da2f52 auto-git:
[add] src/rendering/screen-space-lens-flare.ts
2026-05-19 21:11:46 +02:00
e9dcbe9b01 auto-git:
[change] src/document/migrate-scene-document.ts
 [change] src/document/scene-document.ts
2026-05-19 21:11:46 +02:00
fdf153dc47 Validate advanced rendering lens flare settings in scene documents 2026-05-19 21:09:55 +02:00
ed71146872 Add support and migration for advanced rendering lens flare settings 2026-05-19 21:09:55 +02:00
5a91424f83 Add lens flare settings to advanced rendering configuration 2026-05-19 21:09:29 +02:00
9af35d8bea Format test file for improved readability 2026-05-19 14:38:16 +02:00
b42603b4ce Add test case for Meshopt decoding configuration 2026-05-19 14:38:00 +02:00
786dfa5ecf Add Meshopt decoding support to GLTF loader 2026-05-19 14:38:00 +02:00
0a4e8dd8ee auto-git:
[add] public/basis/
 [change] src/assets/gltf-model-import.ts
 [change] src/assets/ktx2-texture-support.ts
 [change] src/runtime-three/runtime-host.ts
 [change] src/viewport-three/viewport-host.ts
2026-05-19 13:28:18 +02:00
e16bda0ba4 Improve GLTF/GLB loading to support KTX2 textures 2026-05-19 13:26:12 +02:00
fa5d1bd417 Add shared KTX2 loader management utilities 2026-05-19 13:26:12 +02:00
23 changed files with 3910 additions and 393 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -198,6 +198,7 @@ import {
type FaceUvState
} from "../document/brushes";
import {
ADVANCED_RENDERING_ANTI_ALIASING_MODES,
ADVANCED_RENDERING_DYNAMIC_GLOBAL_ILLUMINATION_QUALITIES,
ADVANCED_RENDERING_WATER_REFLECTION_MODES,
BOX_VOLUME_RENDER_PATHS,
@@ -205,10 +206,15 @@ import {
ADVANCED_RENDERING_SHADOW_TYPES,
ADVANCED_RENDERING_TONE_MAPPING_MODES,
FOLIAGE_QUALITY_SHADOW_MODES,
MAX_ADVANCED_RENDERING_LENS_FLARE_GHOST_COUNT,
MAX_FOLIAGE_QUALITY_DENSITY_MULTIPLIER,
MAX_FOLIAGE_QUALITY_MAX_DISTANCE_MULTIPLIER,
MAX_FOLIAGE_QUALITY_WIND_SPEED,
MAX_FOLIAGE_QUALITY_WIND_STRENGTH,
MIN_FOLIAGE_QUALITY_DENSITY_MULTIPLIER,
MIN_FOLIAGE_QUALITY_MAX_DISTANCE_MULTIPLIER,
MIN_FOLIAGE_QUALITY_WIND_SPEED,
MIN_FOLIAGE_QUALITY_WIND_STRENGTH,
areWorldSettingsEqual,
changeWorldBackgroundMode,
cloneWorldSettings,
@@ -220,6 +226,7 @@ import {
type WorldBackgroundSettings,
type AdvancedRenderingSettings,
type AdvancedRenderingDistanceFogSettings,
type AdvancedRenderingAntiAliasingMode,
type BoxVolumeRenderPath,
type AdvancedRenderingWaterReflectionMode,
type AdvancedRenderingDynamicGlobalIlluminationQuality,
@@ -1266,6 +1273,12 @@ function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function normalizeDegrees(value: number): number {
const normalized = value % 360;
return normalized < 0 ? normalized + 360 : normalized;
}
function assertAdvancedRenderingDistanceFogRange(
distanceFog: AdvancedRenderingDistanceFogSettings
) {
@@ -2546,6 +2559,23 @@ function formatAdvancedRenderingToneMappingLabel(
}
}
function formatAdvancedRenderingAntiAliasingModeLabel(
mode: AdvancedRenderingAntiAliasingMode
): string {
switch (mode) {
case "smaa":
return "SMAA";
case "fxaa":
return "FXAA";
case "msaa2x":
return "MSAA 2x";
case "msaa4x":
return "MSAA 4x";
case "msaa8x":
return "MSAA 8x";
}
}
function formatAdvancedRenderingDynamicGlobalIlluminationQualityLabel(
quality: AdvancedRenderingDynamicGlobalIlluminationQuality
): string {
@@ -3819,6 +3849,26 @@ export function App({
editorState.document.world.advancedRendering.foliage.maxDistanceMultiplier
)
);
const [
advancedRenderingFoliageWindStrengthDraft,
setAdvancedRenderingFoliageWindStrengthDraft
] = useState(
String(editorState.document.world.advancedRendering.foliage.windStrength)
);
const [
advancedRenderingFoliageWindSpeedDraft,
setAdvancedRenderingFoliageWindSpeedDraft
] = useState(
String(editorState.document.world.advancedRendering.foliage.windSpeed)
);
const [
advancedRenderingFoliageWindDirectionDraft,
setAdvancedRenderingFoliageWindDirectionDraft
] = useState(
String(
editorState.document.world.advancedRendering.foliage.windDirectionDegrees
)
);
const [
advancedRenderingAmbientOcclusionIntensityDraft,
setAdvancedRenderingAmbientOcclusionIntensityDraft
@@ -4005,6 +4055,32 @@ export function App({
] = useState(
String(editorState.document.world.advancedRendering.godRays.samples)
);
const [
advancedRenderingLensFlareIntensityDraft,
setAdvancedRenderingLensFlareIntensityDraft
] = useState(
String(editorState.document.world.advancedRendering.lensFlare.intensity)
);
const [
advancedRenderingLensFlareHaloSizeDraft,
setAdvancedRenderingLensFlareHaloSizeDraft
] = useState(
String(editorState.document.world.advancedRendering.lensFlare.haloSize)
);
const [
advancedRenderingLensFlareGhostIntensityDraft,
setAdvancedRenderingLensFlareGhostIntensityDraft
] = useState(
String(
editorState.document.world.advancedRendering.lensFlare.ghostIntensity
)
);
const [
advancedRenderingLensFlareGhostCountDraft,
setAdvancedRenderingLensFlareGhostCountDraft
] = useState(
String(editorState.document.world.advancedRendering.lensFlare.ghostCount)
);
const [statusMessage, setStatusMessage] = useState(
initialStatusMessage ?? "Slice 3.5 advanced rendering ready."
);
@@ -5196,6 +5272,15 @@ export function App({
setAdvancedRenderingFoliageMaxDistanceMultiplierDraft(
String(advancedRendering.foliage.maxDistanceMultiplier)
);
setAdvancedRenderingFoliageWindStrengthDraft(
String(advancedRendering.foliage.windStrength)
);
setAdvancedRenderingFoliageWindSpeedDraft(
String(advancedRendering.foliage.windSpeed)
);
setAdvancedRenderingFoliageWindDirectionDraft(
String(advancedRendering.foliage.windDirectionDegrees)
);
setAdvancedRenderingAmbientOcclusionIntensityDraft(
String(advancedRendering.ambientOcclusion.intensity)
);
@@ -5277,6 +5362,18 @@ export function App({
setAdvancedRenderingGodRaysSamplesDraft(
String(advancedRendering.godRays.samples)
);
setAdvancedRenderingLensFlareIntensityDraft(
String(advancedRendering.lensFlare.intensity)
);
setAdvancedRenderingLensFlareHaloSizeDraft(
String(advancedRendering.lensFlare.haloSize)
);
setAdvancedRenderingLensFlareGhostIntensityDraft(
String(advancedRendering.lensFlare.ghostIntensity)
);
setAdvancedRenderingLensFlareGhostCountDraft(
String(advancedRendering.lensFlare.ghostCount)
);
}, [editorState.document.world.advancedRendering]);
useEffect(() => {
@@ -13776,6 +13873,28 @@ export function App({
);
};
const applyAdvancedRenderingAntiAliasingEnabled = (enabled: boolean) => {
applyAdvancedRenderingSettings(
"Set anti-aliasing",
enabled ? "Anti-aliasing enabled." : "Anti-aliasing disabled.",
(advancedRendering) => {
advancedRendering.antiAliasing.enabled = enabled;
}
);
};
const applyAdvancedRenderingAntiAliasingMode = (
mode: AdvancedRenderingAntiAliasingMode
) => {
applyAdvancedRenderingSettings(
"Set anti-aliasing mode",
`Anti-aliasing mode set to ${formatAdvancedRenderingAntiAliasingModeLabel(mode)}.`,
(advancedRendering) => {
advancedRendering.antiAliasing.mode = mode;
}
);
};
const applyAdvancedRenderingFoliageEnabled = (enabled: boolean) => {
applyAdvancedRenderingSettings(
"Set foliage rendering",
@@ -13851,6 +13970,88 @@ export function App({
);
};
const applyAdvancedRenderingFoliageWindEnabled = (enabled: boolean) => {
applyAdvancedRenderingSettings(
"Set foliage wind",
enabled ? "Foliage wind enabled." : "Foliage wind disabled.",
(advancedRendering) => {
advancedRendering.foliage.windEnabled = enabled;
}
);
};
const applyAdvancedRenderingFoliageWindStrength = () => {
try {
const windStrength = clampNumber(
readFiniteNumberDraft(
advancedRenderingFoliageWindStrengthDraft,
"Foliage wind strength"
),
MIN_FOLIAGE_QUALITY_WIND_STRENGTH,
MAX_FOLIAGE_QUALITY_WIND_STRENGTH
);
setAdvancedRenderingFoliageWindStrengthDraft(String(windStrength));
applyAdvancedRenderingSettings(
"Set foliage wind strength",
"Updated the foliage wind strength.",
(advancedRendering) => {
advancedRendering.foliage.windStrength = windStrength;
}
);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyAdvancedRenderingFoliageWindSpeed = () => {
try {
const windSpeed = clampNumber(
readFiniteNumberDraft(
advancedRenderingFoliageWindSpeedDraft,
"Foliage wind speed"
),
MIN_FOLIAGE_QUALITY_WIND_SPEED,
MAX_FOLIAGE_QUALITY_WIND_SPEED
);
setAdvancedRenderingFoliageWindSpeedDraft(String(windSpeed));
applyAdvancedRenderingSettings(
"Set foliage wind speed",
"Updated the foliage wind speed.",
(advancedRendering) => {
advancedRendering.foliage.windSpeed = windSpeed;
}
);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyAdvancedRenderingFoliageWindDirection = () => {
try {
const windDirectionDegrees = normalizeDegrees(
readFiniteNumberDraft(
advancedRenderingFoliageWindDirectionDraft,
"Foliage wind direction"
)
);
setAdvancedRenderingFoliageWindDirectionDraft(
String(windDirectionDegrees)
);
applyAdvancedRenderingSettings(
"Set foliage wind direction",
"Updated the foliage wind direction.",
(advancedRendering) => {
advancedRendering.foliage.windDirectionDegrees = windDirectionDegrees;
}
);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyAdvancedRenderingShadowsEnabled = (enabled: boolean) => {
applyAdvancedRenderingSettings(
"Set advanced rendering shadows",
@@ -14491,6 +14692,93 @@ export function App({
}
};
const applyAdvancedRenderingLensFlareEnabled = (enabled: boolean) => {
applyAdvancedRenderingSettings(
"Set lens flare",
enabled ? "Lens flare enabled." : "Lens flare disabled.",
(advancedRendering) => {
advancedRendering.lensFlare.enabled = enabled;
}
);
};
const applyAdvancedRenderingLensFlareIntensity = () => {
try {
applyAdvancedRenderingSettings(
"Set lens flare intensity",
"Updated the lens flare intensity.",
(advancedRendering) => {
advancedRendering.lensFlare.intensity = readNonNegativeNumberDraft(
advancedRenderingLensFlareIntensityDraft,
"Lens flare intensity"
);
}
);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyAdvancedRenderingLensFlareHaloSize = () => {
try {
applyAdvancedRenderingSettings(
"Set lens flare halo size",
"Updated the lens flare halo size.",
(advancedRendering) => {
advancedRendering.lensFlare.haloSize = readPositiveNumberDraft(
advancedRenderingLensFlareHaloSizeDraft,
"Lens flare halo size"
);
}
);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyAdvancedRenderingLensFlareGhostIntensity = () => {
try {
applyAdvancedRenderingSettings(
"Set lens flare ghost intensity",
"Updated the lens flare ghost intensity.",
(advancedRendering) => {
advancedRendering.lensFlare.ghostIntensity =
readNonNegativeNumberDraft(
advancedRenderingLensFlareGhostIntensityDraft,
"Lens flare ghost intensity"
);
}
);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyAdvancedRenderingLensFlareGhostCount = () => {
try {
const ghostCount = readPositiveIntegerDraft(
advancedRenderingLensFlareGhostCountDraft,
"Lens flare ghost count"
);
if (ghostCount > MAX_ADVANCED_RENDERING_LENS_FLARE_GHOST_COUNT) {
throw new Error(
`Lens flare ghost count must be ${MAX_ADVANCED_RENDERING_LENS_FLARE_GHOST_COUNT} or less.`
);
}
applyAdvancedRenderingSettings(
"Set lens flare ghost count",
"Updated the lens flare ghost count.",
(advancedRendering) => {
advancedRendering.lensFlare.ghostCount = ghostCount;
}
);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyAdvancedRenderingFogPath = (path: BoxVolumeRenderPath) => {
applyAdvancedRenderingSettings(
"Set fog render path",
@@ -20147,10 +20435,168 @@ export function App({
))}
</select>
</label>
<label className="form-field form-field--toggle">
<span className="label">Wind</span>
<input
type="checkbox"
checked={advancedRendering.foliage.windEnabled}
onChange={(event) =>
applyAdvancedRenderingFoliageWindEnabled(
event.currentTarget.checked
)
}
/>
</label>
<div className="vector-inputs vector-inputs--three">
<label className="form-field">
<span className="label">Strength</span>
<input
className="text-input"
type="number"
min={MIN_FOLIAGE_QUALITY_WIND_STRENGTH}
max={MAX_FOLIAGE_QUALITY_WIND_STRENGTH}
step="0.05"
value={advancedRenderingFoliageWindStrengthDraft}
onChange={(event) =>
setAdvancedRenderingFoliageWindStrengthDraft(
event.currentTarget.value
)
}
onBlur={applyAdvancedRenderingFoliageWindStrength}
onKeyDown={(event) =>
handleDraftVectorKeyDown(
event,
applyAdvancedRenderingFoliageWindStrength
)
}
onKeyUp={(event) =>
handleNumberInputKeyUp(
event,
applyAdvancedRenderingFoliageWindStrength
)
}
onPointerUp={(event) =>
handleNumberInputPointerUp(
event,
applyAdvancedRenderingFoliageWindStrength
)
}
/>
</label>
<label className="form-field">
<span className="label">Speed</span>
<input
className="text-input"
type="number"
min={MIN_FOLIAGE_QUALITY_WIND_SPEED}
max={MAX_FOLIAGE_QUALITY_WIND_SPEED}
step="0.05"
value={advancedRenderingFoliageWindSpeedDraft}
onChange={(event) =>
setAdvancedRenderingFoliageWindSpeedDraft(
event.currentTarget.value
)
}
onBlur={applyAdvancedRenderingFoliageWindSpeed}
onKeyDown={(event) =>
handleDraftVectorKeyDown(
event,
applyAdvancedRenderingFoliageWindSpeed
)
}
onKeyUp={(event) =>
handleNumberInputKeyUp(
event,
applyAdvancedRenderingFoliageWindSpeed
)
}
onPointerUp={(event) =>
handleNumberInputPointerUp(
event,
applyAdvancedRenderingFoliageWindSpeed
)
}
/>
</label>
<label className="form-field">
<span className="label">Direction</span>
<input
className="text-input"
type="number"
min="0"
max="360"
step="1"
value={advancedRenderingFoliageWindDirectionDraft}
onChange={(event) =>
setAdvancedRenderingFoliageWindDirectionDraft(
event.currentTarget.value
)
}
onBlur={applyAdvancedRenderingFoliageWindDirection}
onKeyDown={(event) =>
handleDraftVectorKeyDown(
event,
applyAdvancedRenderingFoliageWindDirection
)
}
onKeyUp={(event) =>
handleNumberInputKeyUp(
event,
applyAdvancedRenderingFoliageWindDirection
)
}
onPointerUp={(event) =>
handleNumberInputPointerUp(
event,
applyAdvancedRenderingFoliageWindDirection
)
}
/>
</label>
</div>
</div>
{!advancedRendering.enabled ? null : (
<>
<div className="form-section">
<div className="label">Anti-Aliasing</div>
<label className="form-field form-field--toggle">
<span className="label">Enabled</span>
<input
type="checkbox"
checked={advancedRendering.antiAliasing.enabled}
onChange={(event) =>
applyAdvancedRenderingAntiAliasingEnabled(
event.currentTarget.checked
)
}
/>
</label>
<label className="form-field">
<span className="label">Method</span>
<select
className="select-input"
value={advancedRendering.antiAliasing.mode}
onChange={(event) =>
applyAdvancedRenderingAntiAliasingMode(
event.currentTarget
.value as AdvancedRenderingAntiAliasingMode
)
}
>
{ADVANCED_RENDERING_ANTI_ALIASING_MODES.map(
(mode) => (
<option key={mode} value={mode}>
{formatAdvancedRenderingAntiAliasingModeLabel(
mode
)}
</option>
)
)}
</select>
</label>
</div>
<div className="form-section">
<div className="label">Shadows</div>
<label className="form-field form-field--toggle">
@@ -21451,6 +21897,176 @@ export function App({
</div>
</div>
<div className="form-section">
<div className="label">Lens Flare</div>
<label className="form-field form-field--toggle">
<span className="label">Enabled</span>
<input
type="checkbox"
checked={advancedRendering.lensFlare.enabled}
onChange={(event) =>
applyAdvancedRenderingLensFlareEnabled(
event.currentTarget.checked
)
}
/>
</label>
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">Intensity</span>
<input
className="text-input"
type="number"
min="0"
step="0.05"
value={advancedRenderingLensFlareIntensityDraft}
onChange={(event) =>
setAdvancedRenderingLensFlareIntensityDraft(
event.currentTarget.value
)
}
onBlur={
applyAdvancedRenderingLensFlareIntensity
}
onKeyDown={(event) =>
handleDraftVectorKeyDown(
event,
applyAdvancedRenderingLensFlareIntensity
)
}
onKeyUp={(event) =>
handleNumberInputKeyUp(
event,
applyAdvancedRenderingLensFlareIntensity
)
}
onPointerUp={(event) =>
handleNumberInputPointerUp(
event,
applyAdvancedRenderingLensFlareIntensity
)
}
/>
</label>
<label className="form-field">
<span className="label">Halo Size</span>
<input
className="text-input"
type="number"
min="0.25"
max="3"
step="0.05"
value={advancedRenderingLensFlareHaloSizeDraft}
onChange={(event) =>
setAdvancedRenderingLensFlareHaloSizeDraft(
event.currentTarget.value
)
}
onBlur={applyAdvancedRenderingLensFlareHaloSize}
onKeyDown={(event) =>
handleDraftVectorKeyDown(
event,
applyAdvancedRenderingLensFlareHaloSize
)
}
onKeyUp={(event) =>
handleNumberInputKeyUp(
event,
applyAdvancedRenderingLensFlareHaloSize
)
}
onPointerUp={(event) =>
handleNumberInputPointerUp(
event,
applyAdvancedRenderingLensFlareHaloSize
)
}
/>
</label>
</div>
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">Ghost Strength</span>
<input
className="text-input"
type="number"
min="0"
step="0.05"
value={
advancedRenderingLensFlareGhostIntensityDraft
}
onChange={(event) =>
setAdvancedRenderingLensFlareGhostIntensityDraft(
event.currentTarget.value
)
}
onBlur={
applyAdvancedRenderingLensFlareGhostIntensity
}
onKeyDown={(event) =>
handleDraftVectorKeyDown(
event,
applyAdvancedRenderingLensFlareGhostIntensity
)
}
onKeyUp={(event) =>
handleNumberInputKeyUp(
event,
applyAdvancedRenderingLensFlareGhostIntensity
)
}
onPointerUp={(event) =>
handleNumberInputPointerUp(
event,
applyAdvancedRenderingLensFlareGhostIntensity
)
}
/>
</label>
<label className="form-field">
<span className="label">Ghosts</span>
<input
className="text-input"
type="number"
min="1"
max={
MAX_ADVANCED_RENDERING_LENS_FLARE_GHOST_COUNT
}
step="1"
value={
advancedRenderingLensFlareGhostCountDraft
}
onChange={(event) =>
setAdvancedRenderingLensFlareGhostCountDraft(
event.currentTarget.value
)
}
onBlur={
applyAdvancedRenderingLensFlareGhostCount
}
onKeyDown={(event) =>
handleDraftVectorKeyDown(
event,
applyAdvancedRenderingLensFlareGhostCount
)
}
onKeyUp={(event) =>
handleNumberInputKeyUp(
event,
applyAdvancedRenderingLensFlareGhostCount
)
}
onPointerUp={(event) =>
handleNumberInputPointerUp(
event,
applyAdvancedRenderingLensFlareGhostCount
)
}
/>
</label>
</div>
</div>
<div className="form-section">
<div className="label">Volume Rendering Paths</div>
<div className="vector-inputs vector-inputs--two">

View File

@@ -1,6 +1,19 @@
import { Box3, Cache, Group, Mesh, type AnimationClip, type Material, type Object3D, type Texture } from "three";
import {
Box3,
Cache,
Group,
Mesh,
type AnimationClip,
type Material,
type Object3D,
type Texture
} from "three";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
import {
GLTFLoader,
type GLTF
} from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { createModelInstance, type ModelInstance } from "./model-instances";
@@ -9,6 +22,10 @@ import {
type ModelAssetMetadata,
type ModelAssetRecord
} from "./project-assets";
import {
ensureSharedKtx2LoaderInitialized,
getSharedKtx2LoaderIfInitialized
} from "./ktx2-texture-support";
import { createOpaqueId } from "../core/ids";
import type {
ProjectAssetStorage,
@@ -30,9 +47,14 @@ interface ImportedModelFileSet {
}
const DRACO_DECODER_PATH = "/draco/gltf/";
const GLTF_KTX2_TEXTURE_EXTENSION = "KHR_texture_basisu";
const GLB_MAGIC = 0x46546c67;
const GLB_JSON_CHUNK_TYPE = 0x4e4f534a;
let sharedDracoLoader: DRACOLoader | null = null;
type GltfKtx2Mode = "if-initialized" | "required";
export interface LoadedModelAsset {
assetId: string;
storageKey: string;
@@ -60,8 +82,14 @@ function getFileExtension(sourceName: string): string {
return match === null ? "" : match[1].toLowerCase();
}
function inferFileMimeType(sourceName: string, fallbackMimeType: string): string {
if (fallbackMimeType.trim().length > 0 && fallbackMimeType !== "application/octet-stream") {
function inferFileMimeType(
sourceName: string,
fallbackMimeType: string
): string {
if (
fallbackMimeType.trim().length > 0 &&
fallbackMimeType !== "application/octet-stream"
) {
return fallbackMimeType;
}
@@ -92,22 +120,33 @@ function inferFileMimeType(sourceName: string, fallbackMimeType: string): string
case "gltf":
return "model/gltf+json";
default:
return fallbackMimeType.trim().length > 0 ? fallbackMimeType : "application/octet-stream";
return fallbackMimeType.trim().length > 0
? fallbackMimeType
: "application/octet-stream";
}
}
function inferModelAssetFormat(sourceName: string, mimeType: string): "glb" | "gltf" {
function inferModelAssetFormat(
sourceName: string,
mimeType: string
): "glb" | "gltf" {
const extension = getFileExtension(sourceName);
if (mimeType === "model/gltf-binary" || extension === "glb") {
return "glb";
}
if (mimeType === "model/gltf+json" || mimeType === "application/json" || extension === "gltf") {
if (
mimeType === "model/gltf+json" ||
mimeType === "application/json" ||
extension === "gltf"
) {
return "gltf";
}
throw new Error(`Unsupported model asset format for ${sourceName}. Use .glb or .gltf.`);
throw new Error(
`Unsupported model asset format for ${sourceName}. Use .glb or .gltf.`
);
}
function inferModelMimeType(format: "glb" | "gltf"): string {
@@ -155,8 +194,12 @@ function getPathDirectory(path: string): string {
}
function getRelativePath(fromDirectory: string, targetPath: string): string {
const normalizedFromSegments = normalizeRelativePath(fromDirectory).split("/").filter((segment) => segment.length > 0);
const normalizedTargetSegments = normalizeRelativePath(targetPath).split("/").filter((segment) => segment.length > 0);
const normalizedFromSegments = normalizeRelativePath(fromDirectory)
.split("/")
.filter((segment) => segment.length > 0);
const normalizedTargetSegments = normalizeRelativePath(targetPath)
.split("/")
.filter((segment) => segment.length > 0);
while (
normalizedFromSegments.length > 0 &&
@@ -167,15 +210,119 @@ function getRelativePath(fromDirectory: string, targetPath: string): string {
normalizedTargetSegments.shift();
}
return [...new Array(normalizedFromSegments.length).fill(".."), ...normalizedTargetSegments].join("/");
return [
...new Array(normalizedFromSegments.length).fill(".."),
...normalizedTargetSegments
].join("/");
}
function getImportedFilePath(file: File): string {
const relativePath = typeof file.webkitRelativePath === "string" ? file.webkitRelativePath.trim() : "";
return normalizeRelativePath(relativePath.length > 0 ? relativePath : file.name.trim());
const relativePath =
typeof file.webkitRelativePath === "string"
? file.webkitRelativePath.trim()
: "";
return normalizeRelativePath(
relativePath.length > 0 ? relativePath : file.name.trim()
);
}
function createBoundingBoxFromObject(object: Object3D): ModelAssetMetadata["boundingBox"] {
function stringArrayIncludes(value: unknown, item: string): boolean {
return Array.isArray(value) && value.some((entry) => entry === item);
}
function recordHasKtx2TextureExtension(value: unknown): boolean {
return (
value !== null &&
typeof value === "object" &&
GLTF_KTX2_TEXTURE_EXTENSION in value
);
}
function gltfJsonUsesKtx2Textures(gltfJson: Record<string, unknown>): boolean {
if (
stringArrayIncludes(gltfJson.extensionsUsed, GLTF_KTX2_TEXTURE_EXTENSION) ||
stringArrayIncludes(
gltfJson.extensionsRequired,
GLTF_KTX2_TEXTURE_EXTENSION
)
) {
return true;
}
const textures = Array.isArray(gltfJson.textures) ? gltfJson.textures : [];
return textures.some((texture) => {
if (texture === null || typeof texture !== "object") {
return false;
}
return recordHasKtx2TextureExtension(
(texture as Record<string, unknown>).extensions
);
});
}
function parseGlbJsonChunk(
arrayBuffer: ArrayBuffer
): Record<string, unknown> | null {
if (arrayBuffer.byteLength < 20) {
return null;
}
const dataView = new DataView(arrayBuffer);
if (dataView.getUint32(0, true) !== GLB_MAGIC) {
return null;
}
const version = dataView.getUint32(4, true);
if (version !== 2) {
return null;
}
const declaredLength = dataView.getUint32(8, true);
const byteLength = Math.min(declaredLength, arrayBuffer.byteLength);
let offset = 12;
while (offset + 8 <= byteLength) {
const chunkLength = dataView.getUint32(offset, true);
const chunkType = dataView.getUint32(offset + 4, true);
const chunkStart = offset + 8;
const chunkEnd = chunkStart + chunkLength;
if (chunkEnd > byteLength) {
return null;
}
if (chunkType === GLB_JSON_CHUNK_TYPE) {
const jsonText = new TextDecoder()
.decode(arrayBuffer.slice(chunkStart, chunkEnd))
.trim();
const parsedJson = JSON.parse(jsonText) as unknown;
return parsedJson !== null && typeof parsedJson === "object"
? (parsedJson as Record<string, unknown>)
: null;
}
offset = chunkEnd + ((4 - (chunkLength % 4)) % 4);
}
return null;
}
function glbUsesKtx2Textures(arrayBuffer: ArrayBuffer): boolean {
try {
const gltfJson = parseGlbJsonChunk(arrayBuffer);
return gltfJson === null ? false : gltfJsonUsesKtx2Textures(gltfJson);
} catch {
return false;
}
}
function createBoundingBoxFromObject(
object: Object3D
): ModelAssetMetadata["boundingBox"] {
const box = new Box3().setFromObject(object);
if (box.isEmpty()) {
@@ -214,7 +361,9 @@ function collectMaterialNames(scene: Group): string[] {
return;
}
const materials = Array.isArray(maybeMesh.material) ? maybeMesh.material : [maybeMesh.material];
const materials = Array.isArray(maybeMesh.material)
? maybeMesh.material
: [maybeMesh.material];
for (const material of materials) {
if (material.name.trim().length > 0) {
@@ -226,7 +375,9 @@ function collectMaterialNames(scene: Group): string[] {
return [...names].sort((left, right) => left.localeCompare(right));
}
function collectTextureNames(parserJson: { textures?: Array<{ name?: string }> }): string[] {
function collectTextureNames(parserJson: {
textures?: Array<{ name?: string }>;
}): string[] {
const textures = parserJson.textures ?? [];
const names = new Set<string>();
@@ -241,7 +392,11 @@ function collectTextureNames(parserJson: { textures?: Array<{ name?: string }> }
function collectAnimationNames(gltf: GLTF): string[] {
return gltf.animations
.map((animation, index) => (animation.name.trim().length > 0 ? animation.name : `Animation ${index + 1}`))
.map((animation, index) =>
animation.name.trim().length > 0
? animation.name
: `Animation ${index + 1}`
)
.sort((left, right) => left.localeCompare(right));
}
@@ -255,7 +410,10 @@ function countNodes(scene: Group): number {
return count;
}
export function extractModelAssetMetadata(gltf: GLTF, format: "glb" | "gltf"): ModelAssetMetadata {
export function extractModelAssetMetadata(
gltf: GLTF,
format: "glb" | "gltf"
): ModelAssetMetadata {
gltf.scene.updateMatrixWorld(true);
const boundingBox = createBoundingBoxFromObject(gltf.scene);
@@ -267,7 +425,10 @@ export function extractModelAssetMetadata(gltf: GLTF, format: "glb" | "gltf"): M
}
});
const parserJson = gltf.parser.json as { materials?: Array<{ name?: string }>; textures?: Array<{ name?: string }> };
const parserJson = gltf.parser.json as {
materials?: Array<{ name?: string }>;
textures?: Array<{ name?: string }>;
};
const materialNames = collectMaterialNames(gltf.scene);
const textureNames = collectTextureNames(parserJson);
const animationNames = collectAnimationNames(gltf);
@@ -295,7 +456,9 @@ export function extractModelAssetMetadata(gltf: GLTF, format: "glb" | "gltf"): M
sceneName: gltf.scene.name.trim().length > 0 ? gltf.scene.name : null,
nodeCount: countNodes(gltf.scene),
meshCount: actualMeshCount,
materialNames: [...new Set(materialNames)].sort((left, right) => left.localeCompare(right)),
materialNames: [...new Set(materialNames)].sort((left, right) =>
left.localeCompare(right)
),
textureNames,
animationNames,
boundingBox,
@@ -303,7 +466,11 @@ export function extractModelAssetMetadata(gltf: GLTF, format: "glb" | "gltf"): M
};
}
function createLoadedModelAsset(asset: ModelAssetRecord, template: Group, animations: AnimationClip[]): LoadedModelAsset {
function createLoadedModelAsset(
asset: ModelAssetRecord,
template: Group,
animations: AnimationClip[]
): LoadedModelAsset {
return {
assetId: asset.id,
storageKey: asset.storageKey,
@@ -332,14 +499,32 @@ function createModelAssetRecord(
};
}
async function loadGltfFromArrayBuffer(arrayBuffer: ArrayBuffer): Promise<GLTF> {
const loader = createConfiguredGltfLoader();
async function loadGltfFromArrayBuffer(
arrayBuffer: ArrayBuffer
): Promise<GLTF> {
const loader = createConfiguredGltfLoader({
ktx2: glbUsesKtx2Textures(arrayBuffer) ? "required" : "if-initialized"
});
return loader.parseAsync(arrayBuffer, "");
}
export function createConfiguredGltfLoader(): GLTFLoader {
export function createConfiguredGltfLoader(
options: { ktx2?: GltfKtx2Mode } = {}
): GLTFLoader {
const loader = new GLTFLoader();
loader.setDRACOLoader(getSharedDracoLoader());
loader.setMeshoptDecoder(MeshoptDecoder);
const ktx2Mode = options.ktx2 ?? "if-initialized";
const ktx2Loader =
ktx2Mode === "required"
? ensureSharedKtx2LoaderInitialized()
: getSharedKtx2LoaderIfInitialized();
if (ktx2Loader !== null) {
loader.setKTX2Loader(ktx2Loader);
}
return loader;
}
@@ -352,22 +537,37 @@ function getSharedDracoLoader(): DRACOLoader {
return sharedDracoLoader;
}
function createDataUrlForStoredFile(file: ProjectAssetStorageFileRecord): string {
function createDataUrlForStoredFile(
file: ProjectAssetStorageFileRecord
): string {
const byteArray = new Uint8Array(file.bytes);
let binary = "";
const chunkSize = 0x8000;
for (let index = 0; index < byteArray.length; index += chunkSize) {
binary += String.fromCharCode(...byteArray.subarray(index, index + chunkSize));
binary += String.fromCharCode(
...byteArray.subarray(index, index + chunkSize)
);
}
const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(file.bytes).toString("base64");
const base64 =
typeof btoa === "function"
? btoa(binary)
: Buffer.from(file.bytes).toString("base64");
return `data:${file.mimeType};base64,${base64}`;
}
function createTransientResourceUrl(file: ProjectAssetStorageFileRecord): { revoke: () => void; url: string } {
if (typeof URL.createObjectURL === "function" && typeof Blob !== "undefined") {
const objectUrl = URL.createObjectURL(new Blob([file.bytes], { type: file.mimeType }));
function createTransientResourceUrl(file: ProjectAssetStorageFileRecord): {
revoke: () => void;
url: string;
} {
if (
typeof URL.createObjectURL === "function" &&
typeof Blob !== "undefined"
) {
const objectUrl = URL.createObjectURL(
new Blob([file.bytes], { type: file.mimeType })
);
return {
url: objectUrl,
@@ -440,14 +640,18 @@ function rewriteGltfResourceUris(
return resolvedUri;
};
const buffers = Array.isArray(gltfJson.buffers) ? (gltfJson.buffers as Array<Record<string, unknown>>) : [];
const buffers = Array.isArray(gltfJson.buffers)
? (gltfJson.buffers as Array<Record<string, unknown>>)
: [];
for (const buffer of buffers) {
if (typeof buffer.uri === "string") {
buffer.uri = rewriteUri(buffer.uri);
}
}
const images = Array.isArray(gltfJson.images) ? (gltfJson.images as Array<Record<string, unknown>>) : [];
const images = Array.isArray(gltfJson.images)
? (gltfJson.images as Array<Record<string, unknown>>)
: [];
for (const image of images) {
if (typeof image.uri === "string") {
image.uri = rewriteUri(image.uri);
@@ -494,16 +698,26 @@ function disposeTexture(texture: Texture, seenTextures: Set<Texture>) {
texture.dispose();
}
function disposeMaterialResources(material: Material, disposeTextures: boolean, seenTextures: Set<Texture>) {
function disposeMaterialResources(
material: Material,
disposeTextures: boolean,
seenTextures: Set<Texture>
) {
if (disposeTextures) {
for (const value of Object.values(material as unknown as Record<string, unknown>)) {
for (const value of Object.values(
material as unknown as Record<string, unknown>
)) {
if (value === null || value === undefined) {
continue;
}
if (Array.isArray(value)) {
for (const entry of value) {
if (entry !== null && typeof entry === "object" && "isTexture" in entry) {
if (
entry !== null &&
typeof entry === "object" &&
"isTexture" in entry
) {
disposeTexture(entry as Texture, seenTextures);
}
}
@@ -520,7 +734,11 @@ function disposeMaterialResources(material: Material, disposeTextures: boolean,
material.dispose();
}
function disposeMeshResources(object: Object3D, disposeTextures: boolean, seenTextures: Set<Texture>) {
function disposeMeshResources(
object: Object3D,
disposeTextures: boolean,
seenTextures: Set<Texture>
) {
const maybeMesh = object as Mesh & { isMesh?: boolean };
if (maybeMesh.isMesh !== true) {
@@ -581,7 +799,9 @@ async function loadModelFileSet(files: File[]): Promise<ImportedModelFileSet> {
}
if (modelFiles.length > 1) {
throw new Error("Select exactly one .glb or .gltf file and any matching sidecar resources.");
throw new Error(
"Select exactly one .glb or .gltf file and any matching sidecar resources."
);
}
const rootFile = modelFiles[0];
@@ -597,7 +817,10 @@ async function loadModelFileSet(files: File[]): Promise<ImportedModelFileSet> {
const packageFiles: Record<string, ProjectAssetStorageFileRecord> = {};
for (const { file, bytes } of importedFiles) {
const sourcePath = file === rootFile ? normalizeRelativePath(rootFile.name.trim()) : getRelativePath(rootDirectory, getImportedFilePath(file));
const sourcePath =
file === rootFile
? normalizeRelativePath(rootFile.name.trim())
: getRelativePath(rootDirectory, getImportedFilePath(file));
const mimeType = inferFileMimeType(file.name, file.type);
if (packageFiles[sourcePath] !== undefined) {
@@ -617,7 +840,9 @@ async function loadModelFileSet(files: File[]): Promise<ImportedModelFileSet> {
};
}
const rootEntry = fileEntries.find((entry) => entry.path === normalizeRelativePath(rootFile.name.trim()));
const rootEntry = fileEntries.find(
(entry) => entry.path === normalizeRelativePath(rootFile.name.trim())
);
if (rootEntry === undefined) {
throw new Error(`Unable to locate the root model file ${rootFile.name}.`);
@@ -632,12 +857,20 @@ async function loadModelFileSet(files: File[]): Promise<ImportedModelFileSet> {
fileEntries,
packageRecord,
rootFile: rootEntry,
totalByteLength: fileEntries.reduce((total, entry) => total + entry.bytes.byteLength, 0)
totalByteLength: fileEntries.reduce(
(total, entry) => total + entry.bytes.byteLength,
0
)
};
}
async function loadGltfFromImportedModelFileSet(fileSet: ImportedModelFileSet): Promise<GLTF> {
const rootFormat = inferModelAssetFormat(fileSet.rootFile.path, fileSet.rootFile.mimeType);
async function loadGltfFromImportedModelFileSet(
fileSet: ImportedModelFileSet
): Promise<GLTF> {
const rootFormat = inferModelAssetFormat(
fileSet.rootFile.path,
fileSet.rootFile.mimeType
);
if (rootFormat === "glb") {
return loadGltfFromArrayBuffer(fileSet.rootFile.bytes);
@@ -645,17 +878,24 @@ async function loadGltfFromImportedModelFileSet(fileSet: ImportedModelFileSet):
const text = new TextDecoder().decode(fileSet.rootFile.bytes);
const gltfJson = JSON.parse(text) as Record<string, unknown>;
const { missingUris, revokeUrls } = rewriteGltfResourceUris(gltfJson, fileSet.packageRecord.files);
const { missingUris, revokeUrls } = rewriteGltfResourceUris(
gltfJson,
fileSet.packageRecord.files
);
if (missingUris.length > 0) {
for (const revokeUrl of revokeUrls) {
revokeUrl();
}
throw new Error(`Missing external model resource(s): ${missingUris.join(", ")}.`);
throw new Error(
`Missing external model resource(s): ${missingUris.join(", ")}.`
);
}
const loader = createConfiguredGltfLoader();
const loader = createConfiguredGltfLoader({
ktx2: gltfJsonUsesKtx2Textures(gltfJson) ? "required" : "if-initialized"
});
try {
return await loader.parseAsync(JSON.stringify(gltfJson), "");
@@ -695,11 +935,18 @@ export async function importModelAssetFromFiles(
try {
gltf = await loadGltfFromImportedModelFileSet(fileSet);
} catch (error) {
throw new Error(`Model import failed for ${sourceName}: ${getErrorDetail(error)}`);
throw new Error(
`Model import failed for ${sourceName}: ${getErrorDetail(error)}`
);
}
const metadata = extractModelAssetMetadata(gltf, format);
const asset = createModelAssetRecordFromFileSet(sourceName, mimeType, fileSet.totalByteLength, metadata);
const asset = createModelAssetRecordFromFileSet(
sourceName,
mimeType,
fileSet.totalByteLength,
metadata
);
try {
await storage.putAsset(asset.storageKey, fileSet.packageRecord);
@@ -712,7 +959,11 @@ export async function importModelAssetFromFiles(
return {
asset,
modelInstance,
loadedAsset: createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene), gltf.animations)
loadedAsset: createLoadedModelAsset(
asset,
cloneTemplateScene(gltf.scene),
gltf.animations
)
};
} catch (error) {
await storage.deleteAsset(asset.storageKey).catch(() => undefined);
@@ -755,46 +1006,71 @@ export async function loadModelAssetFromStorage(
try {
storedAsset = await storage.getAsset(asset.storageKey);
} catch (error) {
throw new Error(`Model asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`);
throw new Error(
`Model asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`
);
}
if (storedAsset === null) {
throw new Error(`Missing stored binary data for imported model asset ${asset.sourceName}.`);
throw new Error(
`Missing stored binary data for imported model asset ${asset.sourceName}.`
);
}
const storedModelFile = getStoredModelAssetFile(asset, storedAsset);
if (storedModelFile === null) {
throw new Error(`Missing stored root file for imported model asset ${asset.sourceName}.`);
throw new Error(
`Missing stored root file for imported model asset ${asset.sourceName}.`
);
}
if (asset.metadata.format === "glb") {
try {
const gltf = await loadGltfFromArrayBuffer(storedModelFile.bytes);
return createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene), gltf.animations);
return createLoadedModelAsset(
asset,
cloneTemplateScene(gltf.scene),
gltf.animations
);
} catch (error) {
throw new Error(`Model asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`);
throw new Error(
`Model asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`
);
}
}
const fileEntries = storedAsset.files;
const rootFileBytes = storedModelFile.bytes;
const gltfJson = JSON.parse(new TextDecoder().decode(rootFileBytes)) as Record<string, unknown>;
const { missingUris, revokeUrls } = rewriteGltfResourceUris(gltfJson, fileEntries);
const gltfJson = JSON.parse(
new TextDecoder().decode(rootFileBytes)
) as Record<string, unknown>;
const { missingUris, revokeUrls } = rewriteGltfResourceUris(
gltfJson,
fileEntries
);
if (missingUris.length > 0) {
for (const revokeUrl of revokeUrls) {
revokeUrl();
}
throw new Error(`Missing stored external model resource(s): ${missingUris.join(", ")}.`);
throw new Error(
`Missing stored external model resource(s): ${missingUris.join(", ")}.`
);
}
const loader = createConfiguredGltfLoader();
const loader = createConfiguredGltfLoader({
ktx2: gltfJsonUsesKtx2Textures(gltfJson) ? "required" : "if-initialized"
});
try {
const gltf = await loader.parseAsync(JSON.stringify(gltfJson), "");
return createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene), gltf.animations);
return createLoadedModelAsset(
asset,
cloneTemplateScene(gltf.scene),
gltf.animations
);
} finally {
for (const revokeUrl of revokeUrls) {
revokeUrl();

View File

@@ -0,0 +1,77 @@
import { WebGLRenderer, type WebGLRendererParameters } from "three";
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
const KTX2_TRANSCODER_PATH = "/basis/";
let sharedKtx2Loader: KTX2Loader | null = null;
let sharedKtx2LoaderInitialized = false;
let fallbackRenderer: WebGLRenderer | null = null;
function getErrorDetail(error: unknown): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message.trim();
}
return "Unknown error.";
}
function createSharedKtx2Loader(): KTX2Loader {
const loader = new KTX2Loader();
loader.setTranscoderPath(KTX2_TRANSCODER_PATH);
return loader;
}
function disposeFallbackRendererIfSuperseded(renderer: WebGLRenderer) {
if (fallbackRenderer === null || fallbackRenderer === renderer) {
return;
}
fallbackRenderer.forceContextLoss();
fallbackRenderer.dispose();
fallbackRenderer = null;
}
export function getSharedKtx2LoaderIfInitialized(): KTX2Loader | null {
return sharedKtx2LoaderInitialized ? sharedKtx2Loader : null;
}
export function initializeSharedKtx2Loader(
renderer: WebGLRenderer
): KTX2Loader {
if (sharedKtx2Loader === null) {
sharedKtx2Loader = createSharedKtx2Loader();
}
sharedKtx2Loader.detectSupport(renderer);
sharedKtx2LoaderInitialized = true;
disposeFallbackRendererIfSuperseded(renderer);
return sharedKtx2Loader;
}
function createFallbackRenderer(): WebGLRenderer {
const parameters: WebGLRendererParameters = {
antialias: false,
alpha: false,
powerPreference: "low-power"
};
return new WebGLRenderer(parameters);
}
export function ensureSharedKtx2LoaderInitialized(): KTX2Loader {
if (sharedKtx2LoaderInitialized && sharedKtx2Loader !== null) {
return sharedKtx2Loader;
}
try {
if (fallbackRenderer === null) {
fallbackRenderer = createFallbackRenderer();
}
return initializeSharedKtx2Loader(fallbackRenderer);
} catch (error) {
throw new Error(
`KTX2 texture support could not be initialized. WebGL compressed texture support is required to load KTX2 model textures. ${getErrorDetail(error)}`
);
}
}

View File

@@ -176,6 +176,7 @@ import {
import {
BOX_BRUSH_SCENE_DOCUMENT_VERSION,
ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION,
ANTI_ALIASING_SCENE_DOCUMENT_VERSION,
ATMOSPHERE_POLISH_SCENE_DOCUMENT_VERSION,
AUTHORED_TERRAIN_COLLISION_SCENE_DOCUMENT_VERSION,
AUTHORED_TERRAIN_FOUNDATION_SCENE_DOCUMENT_VERSION,
@@ -187,6 +188,7 @@ import {
CAMERA_RIG_RAIL_SCENE_DOCUMENT_VERSION,
CELESTIAL_ORBIT_SETTINGS_SCENE_DOCUMENT_VERSION,
CONTROL_SURFACE_FOUNDATION_SCENE_DOCUMENT_VERSION,
CUSTOM_PBR_MATERIALS_SCENE_DOCUMENT_VERSION,
DISTANCE_FOG_SCENE_DOCUMENT_VERSION,
DYNAMIC_TERRAIN_LAYERS_SCENE_DOCUMENT_VERSION,
DYNAMIC_GLOBAL_ILLUMINATION_SCENE_DOCUMENT_VERSION,
@@ -195,6 +197,7 @@ import {
FOLIAGE_FOUNDATION_SCENE_DOCUMENT_VERSION,
FOLIAGE_MASKS_SCENE_DOCUMENT_VERSION,
FOLIAGE_QUALITY_SCENE_DOCUMENT_VERSION,
FOLIAGE_WIND_SCENE_DOCUMENT_VERSION,
GOD_RAYS_SCENE_DOCUMENT_VERSION,
GOD_RAYS_SOURCE_SIZE_SCENE_DOCUMENT_VERSION,
DEFAULT_PROJECT_NAME,
@@ -207,6 +210,7 @@ import {
FOUNDATION_SCENE_DOCUMENT_VERSION,
IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION,
LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION,
LENS_FLARE_SCENE_DOCUMENT_VERSION,
MULTI_SCENE_FOUNDATION_SCENE_DOCUMENT_VERSION,
MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION,
PLAYER_START_AIR_DIRECTION_CONTROL_SCENE_DOCUMENT_VERSION,
@@ -374,6 +378,8 @@ import {
createDefaultWorldTimePhaseProfile,
createDefaultWorldShaderSkySettings,
DEFAULT_NIGHT_IMAGE_ENVIRONMENT_INTENSITY,
MAX_ADVANCED_RENDERING_LENS_FLARE_GHOST_COUNT,
isAdvancedRenderingAntiAliasingMode,
isAdvancedRenderingDynamicGlobalIlluminationQuality,
isAdvancedRenderingWaterReflectionMode,
createDefaultAdvancedRenderingSettings,
@@ -882,6 +888,10 @@ function readAdvancedRenderingSettings(
throw new Error("world.advancedRendering.toneMapping must be an object.");
}
if (value.antiAliasing !== undefined && !isRecord(value.antiAliasing)) {
throw new Error("world.advancedRendering.antiAliasing must be an object.");
}
if (value.depthOfField !== undefined && !isRecord(value.depthOfField)) {
throw new Error("world.advancedRendering.depthOfField must be an object.");
}
@@ -898,6 +908,10 @@ function readAdvancedRenderingSettings(
throw new Error("world.advancedRendering.godRays must be an object.");
}
if (value.lensFlare !== undefined && !isRecord(value.lensFlare)) {
throw new Error("world.advancedRendering.lensFlare must be an object.");
}
if (value.foliage !== undefined && !isRecord(value.foliage)) {
throw new Error("world.advancedRendering.foliage must be an object.");
}
@@ -911,6 +925,9 @@ function readAdvancedRenderingSettings(
| undefined;
const bloom = value.bloom as Record<string, unknown> | undefined;
const toneMapping = value.toneMapping as Record<string, unknown> | undefined;
const antiAliasing = value.antiAliasing as
| Record<string, unknown>
| undefined;
const depthOfField = value.depthOfField as
| Record<string, unknown>
| undefined;
@@ -919,6 +936,7 @@ function readAdvancedRenderingSettings(
| undefined;
const distanceFog = value.distanceFog as Record<string, unknown> | undefined;
const godRays = value.godRays as Record<string, unknown> | undefined;
const lensFlare = value.lensFlare as Record<string, unknown> | undefined;
const foliage = value.foliage as Record<string, unknown> | undefined;
const shadowsMapSize = readOptionalAllowedValue(
@@ -939,6 +957,12 @@ function readAdvancedRenderingSettings(
defaults.toneMapping.mode,
isAdvancedRenderingToneMappingMode
);
const antiAliasingMode = readOptionalAllowedValue(
antiAliasing?.mode,
"world.advancedRendering.antiAliasing.mode",
defaults.antiAliasing.mode,
isAdvancedRenderingAntiAliasingMode
);
const dynamicGlobalIlluminationQuality = readOptionalAllowedValue(
dynamicGlobalIllumination?.quality,
"world.advancedRendering.dynamicGlobalIllumination.quality",
@@ -1060,6 +1084,14 @@ function readAdvancedRenderingSettings(
defaults.toneMapping.exposure
)
},
antiAliasing: {
enabled: readOptionalBoolean(
antiAliasing?.enabled,
"world.advancedRendering.antiAliasing.enabled",
defaults.antiAliasing.enabled
),
mode: antiAliasingMode
},
depthOfField: {
enabled: readOptionalBoolean(
depthOfField?.enabled,
@@ -1186,6 +1218,34 @@ function readAdvancedRenderingSettings(
64
)
},
lensFlare: {
enabled: readOptionalBoolean(
lensFlare?.enabled,
"world.advancedRendering.lensFlare.enabled",
defaults.lensFlare.enabled
),
intensity: readOptionalNonNegativeFiniteNumber(
lensFlare?.intensity,
"world.advancedRendering.lensFlare.intensity",
defaults.lensFlare.intensity
),
haloSize: readOptionalPositiveFiniteNumber(
lensFlare?.haloSize,
"world.advancedRendering.lensFlare.haloSize",
defaults.lensFlare.haloSize
),
ghostIntensity: readOptionalNonNegativeFiniteNumber(
lensFlare?.ghostIntensity,
"world.advancedRendering.lensFlare.ghostIntensity",
defaults.lensFlare.ghostIntensity
),
ghostCount: readOptionalPositiveIntegerWithMax(
lensFlare?.ghostCount,
"world.advancedRendering.lensFlare.ghostCount",
defaults.lensFlare.ghostCount,
MAX_ADVANCED_RENDERING_LENS_FLARE_GHOST_COUNT
)
},
foliage: resolveFoliageQualitySettings({
enabled: readOptionalBoolean(
foliage?.enabled,
@@ -1202,7 +1262,27 @@ function readAdvancedRenderingSettings(
"world.advancedRendering.foliage.maxDistanceMultiplier",
defaults.foliage.maxDistanceMultiplier
),
shadows: foliageShadowMode
shadows: foliageShadowMode,
windEnabled: readOptionalBoolean(
foliage?.windEnabled,
"world.advancedRendering.foliage.windEnabled",
defaults.foliage.windEnabled
),
windStrength: readOptionalFiniteNumber(
foliage?.windStrength,
"world.advancedRendering.foliage.windStrength",
defaults.foliage.windStrength
),
windSpeed: readOptionalFiniteNumber(
foliage?.windSpeed,
"world.advancedRendering.foliage.windSpeed",
defaults.foliage.windSpeed
),
windDirectionDegrees: readOptionalFiniteNumber(
foliage?.windDirectionDegrees,
"world.advancedRendering.foliage.windDirectionDegrees",
defaults.foliage.windDirectionDegrees
)
}),
fogPath,
waterPath,
@@ -6841,7 +6921,11 @@ export function migrateSceneDocument(source: unknown): SceneDocument {
source.version !== SPLINE_CORRIDOR_JUNCTIONS_SCENE_DOCUMENT_VERSION &&
source.version !== SPLINE_CORRIDOR_JUNCTION_EDGE_SCENE_DOCUMENT_VERSION &&
source.version !== SPLINE_CORRIDOR_JUNCTION_SHAPE_SCENE_DOCUMENT_VERSION &&
source.version !== NPC_TARGETING_SCENE_DOCUMENT_VERSION
source.version !== NPC_TARGETING_SCENE_DOCUMENT_VERSION &&
source.version !== CUSTOM_PBR_MATERIALS_SCENE_DOCUMENT_VERSION &&
source.version !== LENS_FLARE_SCENE_DOCUMENT_VERSION &&
source.version !== FOLIAGE_WIND_SCENE_DOCUMENT_VERSION &&
source.version !== ANTI_ALIASING_SCENE_DOCUMENT_VERSION
) {
throw new Error(
`Unsupported scene document version: ${String(source.version)}.`

View File

@@ -125,6 +125,7 @@ import {
type Terrain
} from "./terrains";
import {
isAdvancedRenderingAntiAliasingMode,
isAdvancedRenderingWaterReflectionMode,
isAdvancedRenderingDynamicGlobalIlluminationQuality,
isAdvancedRenderingShadowMapSize,
@@ -136,6 +137,11 @@ import {
MAX_FOLIAGE_QUALITY_DENSITY_MULTIPLIER,
MIN_FOLIAGE_QUALITY_MAX_DISTANCE_MULTIPLIER,
MAX_FOLIAGE_QUALITY_MAX_DISTANCE_MULTIPLIER,
MIN_FOLIAGE_QUALITY_WIND_SPEED,
MAX_FOLIAGE_QUALITY_WIND_SPEED,
MIN_FOLIAGE_QUALITY_WIND_STRENGTH,
MAX_FOLIAGE_QUALITY_WIND_STRENGTH,
MAX_ADVANCED_RENDERING_LENS_FLARE_GHOST_COUNT,
isHexColorString,
isWorldShaderSkyPresetId,
type WorldCelestialOrbitAuthoringSettings,
@@ -1130,6 +1136,30 @@ function validateWorldSettings(
);
}
if (!isBoolean(advancedRendering.antiAliasing.enabled)) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-advanced-rendering-anti-aliasing-enabled",
"Advanced rendering anti-aliasing enabled must be a boolean.",
"world.advancedRendering.antiAliasing.enabled"
)
);
}
if (
!isAdvancedRenderingAntiAliasingMode(advancedRendering.antiAliasing.mode)
) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-advanced-rendering-anti-aliasing-mode",
"Advanced rendering anti-aliasing mode must be smaa, fxaa, msaa2x, msaa4x, or msaa8x.",
"world.advancedRendering.antiAliasing.mode"
)
);
}
if (!isBoolean(advancedRendering.depthOfField.enabled)) {
diagnostics.push(
createDiagnostic(
@@ -1233,9 +1263,7 @@ function validateWorldSettings(
);
}
if (
!isNonNegativeFiniteNumber(advancedRendering.distanceFog.nearDistance)
) {
if (!isNonNegativeFiniteNumber(advancedRendering.distanceFog.nearDistance)) {
diagnostics.push(
createDiagnostic(
"error",
@@ -1417,6 +1445,73 @@ function validateWorldSettings(
);
}
if (!isBoolean(advancedRendering.lensFlare.enabled)) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-advanced-rendering-lens-flare-enabled",
"Advanced rendering lens flare enabled must be a boolean.",
"world.advancedRendering.lensFlare.enabled"
)
);
}
if (!isNonNegativeFiniteNumber(advancedRendering.lensFlare.intensity)) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-advanced-rendering-lens-flare-intensity",
"Advanced rendering lens flare intensity must be a non-negative finite number.",
"world.advancedRendering.lensFlare.intensity"
)
);
}
if (!isPositiveFiniteNumber(advancedRendering.lensFlare.haloSize)) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-advanced-rendering-lens-flare-halo-size",
"Advanced rendering lens flare halo size must be a positive finite number.",
"world.advancedRendering.lensFlare.haloSize"
)
);
}
if (!isNonNegativeFiniteNumber(advancedRendering.lensFlare.ghostIntensity)) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-advanced-rendering-lens-flare-ghost-intensity",
"Advanced rendering lens flare ghost intensity must be a non-negative finite number.",
"world.advancedRendering.lensFlare.ghostIntensity"
)
);
}
if (!isPositiveInteger(advancedRendering.lensFlare.ghostCount)) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-advanced-rendering-lens-flare-ghost-count",
"Advanced rendering lens flare ghost count must be a positive integer.",
"world.advancedRendering.lensFlare.ghostCount"
)
);
} else if (
advancedRendering.lensFlare.ghostCount >
MAX_ADVANCED_RENDERING_LENS_FLARE_GHOST_COUNT
) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-advanced-rendering-lens-flare-ghost-count-range",
`Advanced rendering lens flare ghost count must be ${MAX_ADVANCED_RENDERING_LENS_FLARE_GHOST_COUNT} or less.`,
"world.advancedRendering.lensFlare.ghostCount"
)
);
}
if (!isBoolean(advancedRendering.foliage.enabled)) {
diagnostics.push(
createDiagnostic(
@@ -1473,6 +1568,68 @@ function validateWorldSettings(
);
}
if (!isBoolean(advancedRendering.foliage.windEnabled)) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-foliage-quality-wind-enabled",
"Foliage wind enabled must be a boolean.",
"world.advancedRendering.foliage.windEnabled"
)
);
}
if (
!isFiniteNumberInRange(
advancedRendering.foliage.windStrength,
MIN_FOLIAGE_QUALITY_WIND_STRENGTH,
MAX_FOLIAGE_QUALITY_WIND_STRENGTH
)
) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-foliage-quality-wind-strength",
`Foliage wind strength must be a finite number between ${MIN_FOLIAGE_QUALITY_WIND_STRENGTH} and ${MAX_FOLIAGE_QUALITY_WIND_STRENGTH}.`,
"world.advancedRendering.foliage.windStrength"
)
);
}
if (
!isFiniteNumberInRange(
advancedRendering.foliage.windSpeed,
MIN_FOLIAGE_QUALITY_WIND_SPEED,
MAX_FOLIAGE_QUALITY_WIND_SPEED
)
) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-foliage-quality-wind-speed",
`Foliage wind speed must be a finite number between ${MIN_FOLIAGE_QUALITY_WIND_SPEED} and ${MAX_FOLIAGE_QUALITY_WIND_SPEED}.`,
"world.advancedRendering.foliage.windSpeed"
)
);
}
if (
!isFiniteNumberInRange(
advancedRendering.foliage.windDirectionDegrees,
0,
360
)
) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-foliage-quality-wind-direction",
"Foliage wind direction must be a finite number between 0 and 360 degrees.",
"world.advancedRendering.foliage.windDirectionDegrees"
)
);
}
if (!isBoxVolumeRenderPath(advancedRendering.fogPath)) {
diagnostics.push(
createDiagnostic(
@@ -3454,7 +3611,12 @@ function validateSplineCorridorJunction(
)
);
} else {
validateScenePathRoadEdge(junction.edge, `${path}.edge`, document, diagnostics);
validateScenePathRoadEdge(
junction.edge,
`${path}.edge`,
document,
diagnostics
);
}
if (!Array.isArray(junction.connections) || junction.connections.length < 2) {
@@ -3793,8 +3955,7 @@ function validateTerrain(
);
}
const expectedFoliageMaskValueCount =
mask.resolutionX * mask.resolutionZ;
const expectedFoliageMaskValueCount = mask.resolutionX * mask.resolutionZ;
if (mask.values.length !== expectedFoliageMaskValueCount) {
diagnostics.push(
@@ -5015,9 +5176,7 @@ function validatePlayerStartEntity(
);
}
if (
!isPlayerStartGamepadActionBinding(entity.inputBindings?.gamepad.climb)
) {
if (!isPlayerStartGamepadActionBinding(entity.inputBindings?.gamepad.climb)) {
diagnostics.push(
createDiagnostic(
"error",
@@ -5800,10 +5959,7 @@ function validateNpcEntity(
);
}
if (
typeof entity.targetAnchor !== "object" ||
entity.targetAnchor === null
) {
if (typeof entity.targetAnchor !== "object" || entity.targetAnchor === null) {
diagnostics.push(
createDiagnostic(
"error",

View File

@@ -36,10 +36,15 @@ import {
type FoliagePrototypeRegistry
} from "../foliage/foliage";
export const SCENE_DOCUMENT_VERSION = 110 as const;
export const SCENE_DOCUMENT_VERSION = 113 as const;
export const ANTI_ALIASING_SCENE_DOCUMENT_VERSION = 113 as const;
export const FOLIAGE_WIND_SCENE_DOCUMENT_VERSION = 112 as const;
export const LENS_FLARE_SCENE_DOCUMENT_VERSION = 111 as const;
export const CUSTOM_PBR_MATERIALS_SCENE_DOCUMENT_VERSION = 110 as const;
export const SPLINE_CORRIDOR_JUNCTION_SHAPE_SCENE_DOCUMENT_VERSION = 109 as const;
export const SPLINE_CORRIDOR_JUNCTION_EDGE_SCENE_DOCUMENT_VERSION = 108 as const;
export const SPLINE_CORRIDOR_JUNCTION_SHAPE_SCENE_DOCUMENT_VERSION =
109 as const;
export const SPLINE_CORRIDOR_JUNCTION_EDGE_SCENE_DOCUMENT_VERSION =
108 as const;
export const SPLINE_CORRIDOR_JUNCTIONS_SCENE_DOCUMENT_VERSION = 107 as const;
export const SPLINE_CORRIDOR_COLLISION_SCENE_DOCUMENT_VERSION = 106 as const;
export const SPLINE_REPEATERS_SCENE_DOCUMENT_VERSION = 105 as const;

View File

@@ -26,6 +26,13 @@ export const ADVANCED_RENDERING_TONE_MAPPING_MODES = [
"cineon",
"acesFilmic"
] as const;
export const ADVANCED_RENDERING_ANTI_ALIASING_MODES = [
"smaa",
"fxaa",
"msaa2x",
"msaa4x",
"msaa8x"
] as const;
export const BOX_VOLUME_RENDER_PATHS = ["performance", "quality"] as const;
export const ADVANCED_RENDERING_WATER_REFLECTION_MODES = [
"none",
@@ -41,6 +48,10 @@ export const MIN_FOLIAGE_QUALITY_DENSITY_MULTIPLIER = 0 as const;
export const MAX_FOLIAGE_QUALITY_DENSITY_MULTIPLIER = 8 as const;
export const MIN_FOLIAGE_QUALITY_MAX_DISTANCE_MULTIPLIER = 0.1 as const;
export const MAX_FOLIAGE_QUALITY_MAX_DISTANCE_MULTIPLIER = 2 as const;
export const MIN_FOLIAGE_QUALITY_WIND_STRENGTH = 0 as const;
export const MAX_FOLIAGE_QUALITY_WIND_STRENGTH = 4 as const;
export const MIN_FOLIAGE_QUALITY_WIND_SPEED = 0 as const;
export const MAX_FOLIAGE_QUALITY_WIND_SPEED = 8 as const;
export type AdvancedRenderingShadowMapSize =
(typeof ADVANCED_RENDERING_SHADOW_MAP_SIZES)[number];
@@ -48,6 +59,8 @@ export type AdvancedRenderingShadowType =
(typeof ADVANCED_RENDERING_SHADOW_TYPES)[number];
export type AdvancedRenderingToneMappingMode =
(typeof ADVANCED_RENDERING_TONE_MAPPING_MODES)[number];
export type AdvancedRenderingAntiAliasingMode =
(typeof ADVANCED_RENDERING_ANTI_ALIASING_MODES)[number];
export type BoxVolumeRenderPath = (typeof BOX_VOLUME_RENDER_PATHS)[number];
export type AdvancedRenderingWaterReflectionMode =
(typeof ADVANCED_RENDERING_WATER_REFLECTION_MODES)[number];
@@ -161,6 +174,11 @@ export interface AdvancedRenderingToneMappingSettings {
exposure: number;
}
export interface AdvancedRenderingAntiAliasingSettings {
enabled: boolean;
mode: AdvancedRenderingAntiAliasingMode;
}
export interface AdvancedRenderingDepthOfFieldSettings {
enabled: boolean;
focusDistance: number;
@@ -196,11 +214,23 @@ export interface AdvancedRenderingGodRaysSettings {
samples: number;
}
export interface AdvancedRenderingLensFlareSettings {
enabled: boolean;
intensity: number;
haloSize: number;
ghostIntensity: number;
ghostCount: number;
}
export interface FoliageQualitySettings {
enabled: boolean;
densityMultiplier: number;
maxDistanceMultiplier: number;
shadows: FoliageQualityShadowMode;
windEnabled: boolean;
windStrength: number;
windSpeed: number;
windDirectionDegrees: number;
}
export interface AdvancedRenderingSettings {
@@ -210,10 +240,12 @@ export interface AdvancedRenderingSettings {
dynamicGlobalIllumination: AdvancedRenderingDynamicGlobalIlluminationSettings;
bloom: AdvancedRenderingBloomSettings;
toneMapping: AdvancedRenderingToneMappingSettings;
antiAliasing: AdvancedRenderingAntiAliasingSettings;
depthOfField: AdvancedRenderingDepthOfFieldSettings;
whiteboxBevel: AdvancedRenderingWhiteboxBevelSettings;
distanceFog: AdvancedRenderingDistanceFogSettings;
godRays: AdvancedRenderingGodRaysSettings;
lensFlare: AdvancedRenderingLensFlareSettings;
foliage: FoliageQualitySettings;
fogPath: BoxVolumeRenderPath;
waterPath: BoxVolumeRenderPath;
@@ -300,6 +332,9 @@ const DEFAULT_ADVANCED_RENDERING_BLOOM_RADIUS = 0.35;
const DEFAULT_ADVANCED_RENDERING_TONE_MAPPING_MODE: AdvancedRenderingToneMappingMode =
"acesFilmic";
const DEFAULT_ADVANCED_RENDERING_TONE_MAPPING_EXPOSURE = 1;
const DEFAULT_ADVANCED_RENDERING_ANTI_ALIASING_ENABLED = true;
const DEFAULT_ADVANCED_RENDERING_ANTI_ALIASING_MODE: AdvancedRenderingAntiAliasingMode =
"smaa";
const DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_FOCUS_DISTANCE = 10;
const DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_FOCAL_LENGTH = 0.03;
const DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_BOKEH_SCALE = 1.5;
@@ -319,10 +354,19 @@ const DEFAULT_ADVANCED_RENDERING_GOD_RAYS_EXPOSURE = 0.28;
const DEFAULT_ADVANCED_RENDERING_GOD_RAYS_DENSITY = 0.72;
const DEFAULT_ADVANCED_RENDERING_GOD_RAYS_SOURCE_SIZE = 1;
const DEFAULT_ADVANCED_RENDERING_GOD_RAYS_SAMPLES = 48;
const DEFAULT_ADVANCED_RENDERING_LENS_FLARE_INTENSITY = 0.55;
const DEFAULT_ADVANCED_RENDERING_LENS_FLARE_HALO_SIZE = 1;
const DEFAULT_ADVANCED_RENDERING_LENS_FLARE_GHOST_INTENSITY = 0.65;
const DEFAULT_ADVANCED_RENDERING_LENS_FLARE_GHOST_COUNT = 5;
export const MAX_ADVANCED_RENDERING_LENS_FLARE_GHOST_COUNT = 8 as const;
const DEFAULT_FOLIAGE_QUALITY_ENABLED = true;
const DEFAULT_FOLIAGE_QUALITY_DENSITY_MULTIPLIER = 1;
const DEFAULT_FOLIAGE_QUALITY_MAX_DISTANCE_MULTIPLIER = 1;
const DEFAULT_FOLIAGE_QUALITY_SHADOWS: FoliageQualityShadowMode = "near";
const DEFAULT_FOLIAGE_QUALITY_WIND_ENABLED = true;
const DEFAULT_FOLIAGE_QUALITY_WIND_STRENGTH = 0.65;
const DEFAULT_FOLIAGE_QUALITY_WIND_SPEED = 1;
const DEFAULT_FOLIAGE_QUALITY_WIND_DIRECTION_DEGREES = 35;
const DEFAULT_BOX_VOLUME_RENDER_PATH: BoxVolumeRenderPath = "performance";
const DEFAULT_ADVANCED_RENDERING_WATER_REFLECTION_MODE: AdvancedRenderingWaterReflectionMode =
"none";
@@ -512,6 +556,14 @@ export function isAdvancedRenderingToneMappingMode(
);
}
export function isAdvancedRenderingAntiAliasingMode(
value: unknown
): value is AdvancedRenderingAntiAliasingMode {
return ADVANCED_RENDERING_ANTI_ALIASING_MODES.includes(
value as AdvancedRenderingAntiAliasingMode
);
}
export function isBoxVolumeRenderPath(
value: unknown
): value is BoxVolumeRenderPath {
@@ -547,7 +599,11 @@ export function createDefaultFoliageQualitySettings(): FoliageQualitySettings {
enabled: DEFAULT_FOLIAGE_QUALITY_ENABLED,
densityMultiplier: DEFAULT_FOLIAGE_QUALITY_DENSITY_MULTIPLIER,
maxDistanceMultiplier: DEFAULT_FOLIAGE_QUALITY_MAX_DISTANCE_MULTIPLIER,
shadows: DEFAULT_FOLIAGE_QUALITY_SHADOWS
shadows: DEFAULT_FOLIAGE_QUALITY_SHADOWS,
windEnabled: DEFAULT_FOLIAGE_QUALITY_WIND_ENABLED,
windStrength: DEFAULT_FOLIAGE_QUALITY_WIND_STRENGTH,
windSpeed: DEFAULT_FOLIAGE_QUALITY_WIND_SPEED,
windDirectionDegrees: DEFAULT_FOLIAGE_QUALITY_WIND_DIRECTION_DEGREES
};
}
@@ -560,25 +616,51 @@ export function resolveFoliageQualitySettings(
return defaults;
}
const candidate = settings as Partial<FoliageQualitySettings>;
return {
enabled: settings.enabled,
densityMultiplier: Number.isFinite(settings.densityMultiplier)
enabled:
typeof candidate.enabled === "boolean"
? candidate.enabled
: defaults.enabled,
densityMultiplier: Number.isFinite(candidate.densityMultiplier)
? clamp(
settings.densityMultiplier,
candidate.densityMultiplier!,
MIN_FOLIAGE_QUALITY_DENSITY_MULTIPLIER,
MAX_FOLIAGE_QUALITY_DENSITY_MULTIPLIER
)
: defaults.densityMultiplier,
maxDistanceMultiplier: Number.isFinite(settings.maxDistanceMultiplier)
maxDistanceMultiplier: Number.isFinite(candidate.maxDistanceMultiplier)
? clamp(
settings.maxDistanceMultiplier,
candidate.maxDistanceMultiplier!,
MIN_FOLIAGE_QUALITY_MAX_DISTANCE_MULTIPLIER,
MAX_FOLIAGE_QUALITY_MAX_DISTANCE_MULTIPLIER
)
: defaults.maxDistanceMultiplier,
shadows: isFoliageQualityShadowMode(settings.shadows)
? settings.shadows
: defaults.shadows
shadows: isFoliageQualityShadowMode(candidate.shadows)
? candidate.shadows
: defaults.shadows,
windEnabled:
typeof candidate.windEnabled === "boolean"
? candidate.windEnabled
: defaults.windEnabled,
windStrength: Number.isFinite(candidate.windStrength)
? clamp(
candidate.windStrength!,
MIN_FOLIAGE_QUALITY_WIND_STRENGTH,
MAX_FOLIAGE_QUALITY_WIND_STRENGTH
)
: defaults.windStrength,
windSpeed: Number.isFinite(candidate.windSpeed)
? clamp(
candidate.windSpeed!,
MIN_FOLIAGE_QUALITY_WIND_SPEED,
MAX_FOLIAGE_QUALITY_WIND_SPEED
)
: defaults.windSpeed,
windDirectionDegrees: Number.isFinite(candidate.windDirectionDegrees)
? normalizeDegrees(candidate.windDirectionDegrees!)
: defaults.windDirectionDegrees
};
}
@@ -598,7 +680,11 @@ export function areFoliageQualitySettingsEqual(
left.enabled === right.enabled &&
left.densityMultiplier === right.densityMultiplier &&
left.maxDistanceMultiplier === right.maxDistanceMultiplier &&
left.shadows === right.shadows
left.shadows === right.shadows &&
left.windEnabled === right.windEnabled &&
left.windStrength === right.windStrength &&
left.windSpeed === right.windSpeed &&
left.windDirectionDegrees === right.windDirectionDegrees
);
}
@@ -634,6 +720,10 @@ export function createDefaultAdvancedRenderingSettings(): AdvancedRenderingSetti
mode: DEFAULT_ADVANCED_RENDERING_TONE_MAPPING_MODE,
exposure: DEFAULT_ADVANCED_RENDERING_TONE_MAPPING_EXPOSURE
},
antiAliasing: {
enabled: DEFAULT_ADVANCED_RENDERING_ANTI_ALIASING_ENABLED,
mode: DEFAULT_ADVANCED_RENDERING_ANTI_ALIASING_MODE
},
depthOfField: {
enabled: false,
focusDistance: DEFAULT_ADVANCED_RENDERING_DEPTH_OF_FIELD_FOCUS_DISTANCE,
@@ -665,6 +755,13 @@ export function createDefaultAdvancedRenderingSettings(): AdvancedRenderingSetti
sourceSize: DEFAULT_ADVANCED_RENDERING_GOD_RAYS_SOURCE_SIZE,
samples: DEFAULT_ADVANCED_RENDERING_GOD_RAYS_SAMPLES
},
lensFlare: {
enabled: false,
intensity: DEFAULT_ADVANCED_RENDERING_LENS_FLARE_INTENSITY,
haloSize: DEFAULT_ADVANCED_RENDERING_LENS_FLARE_HALO_SIZE,
ghostIntensity: DEFAULT_ADVANCED_RENDERING_LENS_FLARE_GHOST_INTENSITY,
ghostCount: DEFAULT_ADVANCED_RENDERING_LENS_FLARE_GHOST_COUNT
},
foliage: createDefaultFoliageQualitySettings(),
fogPath: DEFAULT_BOX_VOLUME_RENDER_PATH,
waterPath: DEFAULT_BOX_VOLUME_RENDER_PATH,
@@ -982,6 +1079,9 @@ export function cloneAdvancedRenderingSettings(
toneMapping: {
...settings.toneMapping
},
antiAliasing: {
...settings.antiAliasing
},
depthOfField: {
...settings.depthOfField
},
@@ -994,6 +1094,9 @@ export function cloneAdvancedRenderingSettings(
godRays: {
...settings.godRays
},
lensFlare: {
...settings.lensFlare
},
foliage: cloneFoliageQualitySettings(settings.foliage),
fogPath: settings.fogPath,
waterPath: settings.waterPath,
@@ -1175,6 +1278,8 @@ export function areAdvancedRenderingSettingsEqual(
left.bloom.radius === right.bloom.radius &&
left.toneMapping.mode === right.toneMapping.mode &&
left.toneMapping.exposure === right.toneMapping.exposure &&
left.antiAliasing.enabled === right.antiAliasing.enabled &&
left.antiAliasing.mode === right.antiAliasing.mode &&
left.depthOfField.enabled === right.depthOfField.enabled &&
left.depthOfField.focusDistance === right.depthOfField.focusDistance &&
left.depthOfField.focalLength === right.depthOfField.focalLength &&
@@ -1198,6 +1303,11 @@ export function areAdvancedRenderingSettingsEqual(
left.godRays.density === right.godRays.density &&
left.godRays.sourceSize === right.godRays.sourceSize &&
left.godRays.samples === right.godRays.samples &&
left.lensFlare.enabled === right.lensFlare.enabled &&
left.lensFlare.intensity === right.lensFlare.intensity &&
left.lensFlare.haloSize === right.lensFlare.haloSize &&
left.lensFlare.ghostIntensity === right.lensFlare.ghostIntensity &&
left.lensFlare.ghostCount === right.lensFlare.ghostCount &&
areFoliageQualitySettingsEqual(left.foliage, right.foliage) &&
left.fogPath === right.fogPath &&
left.waterPath === right.waterPath &&

View File

@@ -3,12 +3,16 @@ import {
Color,
Frustum,
Group,
InstancedBufferAttribute,
InstancedMesh,
Matrix4,
Mesh,
MeshStandardMaterial,
Sphere,
Vector2,
Vector3,
type BufferGeometry,
type IUniform,
type Material
} from "three";
@@ -61,6 +65,28 @@ interface FoliageTemplateSourceMesh {
}
const VIEW_SIGNATURE_PRECISION = 100;
const FOLIAGE_WIND_ATTRIBUTE_NAME = "foliageWind";
const FOLIAGE_WIND_SHADER_KEY = "foliage-wind-v1";
type FoliageWindCompileShader = Parameters<
MeshStandardMaterial["onBeforeCompile"]
>[0];
type FoliageWindCompileRenderer = Parameters<
MeshStandardMaterial["onBeforeCompile"]
>[1];
interface FoliageWindUniformSet {
time: IUniform<number>;
strength: IUniform<number>;
speed: IUniform<number>;
direction: IUniform<Vector2>;
}
interface FoliageWindMaterialOptions {
getSettings: () => FoliageQualitySettings;
getTime: () => number;
registerUniforms: (uniforms: FoliageWindUniformSet) => void;
}
function stableStringify(value: unknown): string {
if (
@@ -88,14 +114,131 @@ function stableStringify(value: unknown): string {
return JSON.stringify(String(value));
}
function cloneMaterial(material: Material): Material {
return material.clone();
function writeFoliageWindDirectionVector(
target: Vector2,
settings: FoliageQualitySettings
): Vector2 {
const radians = (settings.windDirectionDegrees * Math.PI) / 180;
return target.set(Math.cos(radians), Math.sin(radians)).normalize();
}
function cloneMaterialSet(material: Material | Material[]): Material | Material[] {
function createFoliageWindUniformSet(
settings: FoliageQualitySettings,
time: number
): FoliageWindUniformSet {
return {
time: { value: time },
strength: { value: settings.windStrength },
speed: { value: settings.windSpeed },
direction: {
value: writeFoliageWindDirectionVector(new Vector2(), settings)
}
};
}
function writeFoliageWindUniformValues(
uniforms: FoliageWindUniformSet,
settings: FoliageQualitySettings,
time: number
) {
uniforms.time.value = time;
uniforms.strength.value = settings.windStrength;
uniforms.speed.value = settings.windSpeed;
writeFoliageWindDirectionVector(uniforms.direction.value, settings);
}
function patchFoliageWindMaterial(
material: MeshStandardMaterial,
options: FoliageWindMaterialOptions
) {
const previousOnBeforeCompile = material.onBeforeCompile.bind(material);
const previousCustomProgramCacheKey =
material.customProgramCacheKey.bind(material);
material.onBeforeCompile = (
shader: FoliageWindCompileShader,
renderer: FoliageWindCompileRenderer
) => {
previousOnBeforeCompile(shader, renderer);
const uniforms = createFoliageWindUniformSet(
options.getSettings(),
options.getTime()
);
shader.uniforms.foliageWindTime = uniforms.time;
shader.uniforms.foliageWindStrength = uniforms.strength;
shader.uniforms.foliageWindSpeed = uniforms.speed;
shader.uniforms.foliageWindDirection = uniforms.direction;
shader.vertexShader = shader.vertexShader
.replace(
"#include <common>",
`#include <common>
attribute vec2 ${FOLIAGE_WIND_ATTRIBUTE_NAME};
uniform float foliageWindTime;
uniform float foliageWindStrength;
uniform float foliageWindSpeed;
uniform vec2 foliageWindDirection;`
)
.replace(
"#include <project_vertex>",
`vec4 mvPosition = vec4( transformed, 1.0 );
#ifdef USE_BATCHING
mvPosition = batchingMatrix * mvPosition;
#endif
#ifdef USE_INSTANCING
mvPosition = instanceMatrix * mvPosition;
#endif
float foliageWindHeightMask = clamp(position.y, 0.0, 1.0);
foliageWindHeightMask *= foliageWindHeightMask;
float foliageWindInstanceStrength = max(${FOLIAGE_WIND_ATTRIBUTE_NAME}.y, 0.0);
float foliageWindAmount =
foliageWindStrength * foliageWindInstanceStrength * foliageWindHeightMask;
vec2 foliageWindSideDirection = vec2(-foliageWindDirection.y, foliageWindDirection.x);
float foliageWindPhase =
dot(mvPosition.xz, foliageWindDirection * 0.22) +
${FOLIAGE_WIND_ATTRIBUTE_NAME}.x +
foliageWindTime * foliageWindSpeed;
float foliageWindPrimary = sin(foliageWindPhase);
float foliageWindSecondary = sin(foliageWindPhase * 1.73 + foliageWindTime * foliageWindSpeed * 0.37);
mvPosition.xz += foliageWindDirection * foliageWindPrimary * foliageWindAmount * 0.18;
mvPosition.xz += foliageWindSideDirection * foliageWindSecondary * foliageWindAmount * 0.045;
mvPosition.y += foliageWindSecondary * foliageWindAmount * 0.025;
mvPosition = modelViewMatrix * mvPosition;
gl_Position = projectionMatrix * mvPosition;`
);
options.registerUniforms(uniforms);
};
material.customProgramCacheKey = () =>
`${previousCustomProgramCacheKey()}|${FOLIAGE_WIND_SHADER_KEY}`;
material.needsUpdate = true;
}
function cloneMaterial(
material: Material,
windOptions?: FoliageWindMaterialOptions
): Material {
const clonedMaterial = material.clone();
if (
windOptions !== undefined &&
clonedMaterial instanceof MeshStandardMaterial
) {
patchFoliageWindMaterial(clonedMaterial, windOptions);
}
return clonedMaterial;
}
function cloneMaterialSet(
material: Material | Material[],
windOptions?: FoliageWindMaterialOptions
): Material | Material[] {
return Array.isArray(material)
? material.map((entry) => cloneMaterial(entry))
: cloneMaterial(material);
? material.map((entry) => cloneMaterial(entry, windOptions))
: cloneMaterial(material, windOptions);
}
function disposeMaterial(material: Material | Material[]) {
@@ -242,18 +385,35 @@ function createFoliageRenderResourceSignature(options: {
quality: {
enabled: options.quality.enabled,
densityMultiplier: options.quality.densityMultiplier,
shadows: options.quality.shadows
shadows: options.quality.shadows,
windEnabled: options.quality.windEnabled
}
});
}
function createFoliageWindAttribute(
instances: FoliageRenderBatch["instances"]
): InstancedBufferAttribute {
const values = new Float32Array(instances.length * 2);
for (let index = 0; index < instances.length; index += 1) {
const instance = instances[index]!;
values[index * 2] = instance.windPhase;
values[index * 2 + 1] = Math.max(0, instance.windStrength);
}
return new InstancedBufferAttribute(values, 2);
}
function createInstancedMeshForSource(
batch: FoliageRenderBatch,
sourceMesh: FoliageTemplateSourceMesh
sourceMesh: FoliageTemplateSourceMesh,
windOptions?: FoliageWindMaterialOptions
): InstancedMesh {
const mesh = new InstancedMesh(
sourceMesh.geometry.clone(),
cloneMaterialSet(sourceMesh.material),
cloneMaterialSet(sourceMesh.material, windOptions),
batch.instances.length
);
const color = new Color();
@@ -287,6 +447,13 @@ function createInstancedMeshForSource(
mesh.instanceMatrix.needsUpdate = true;
if (windOptions !== undefined) {
mesh.geometry.setAttribute(
FOLIAGE_WIND_ATTRIBUTE_NAME,
createFoliageWindAttribute(batch.instances)
);
}
if (mesh.instanceColor !== null) {
mesh.instanceColor.needsUpdate = true;
}
@@ -309,6 +476,8 @@ export class FoliageInstancedRenderer {
private scatter: FoliageScatterResult | null = null;
private prototypeRegistry: FoliagePrototypeRegistry = {};
private quality: FoliageQualitySettings = resolveFoliageQualitySettings(null);
private windTime = 0;
private windUniforms: FoliageWindUniformSet[] = [];
private currentView: FoliageRenderView | null = null;
private viewSignature: string | null = null;
private renderResourceSignature: string | null = null;
@@ -334,6 +503,18 @@ export class FoliageInstancedRenderer {
this.group.userData.nonPickable = true;
}
updateWind(deltaSeconds: number) {
if (!this.shouldApplyWindShader()) {
return;
}
if (Number.isFinite(deltaSeconds) && deltaSeconds > 0) {
this.windTime += deltaSeconds;
}
this.writeWindUniforms();
}
sync(input: FoliageInstancedRendererSyncInput) {
const terrains = normalizeTerrainRegistry(input.terrains);
const quality = resolveFoliageQualitySettings(input.quality);
@@ -372,6 +553,7 @@ export class FoliageInstancedRenderer {
renderResourceSignature === this.renderResourceSignature &&
this.scatter !== null
) {
this.writeWindUniforms();
this.applyCurrentViewToRenderResources();
return;
}
@@ -504,6 +686,7 @@ export class FoliageInstancedRenderer {
this.currentView = null;
this.viewSignature = null;
this.renderResourceSignature = null;
this.windUniforms = [];
this.sourceMeshPromisesByBundledPath.clear();
this.clearActiveBatches();
}
@@ -513,6 +696,7 @@ export class FoliageInstancedRenderer {
this.activeBatchKeyByChunkKey.clear();
this.activeLodLevelByChunkKey.clear();
this.renderChunks = [];
this.windUniforms = [];
if (this.activeBatchGroup === null) {
return;
@@ -532,6 +716,41 @@ export class FoliageInstancedRenderer {
console.warn(message);
}
private shouldApplyWindShader(): boolean {
return (
this.quality.enabled &&
this.quality.densityMultiplier > 0 &&
this.quality.windEnabled
);
}
private registerWindUniforms = (uniforms: FoliageWindUniformSet) => {
writeFoliageWindUniformValues(uniforms, this.quality, this.windTime);
this.windUniforms.push(uniforms);
};
private writeWindUniforms() {
if (!this.shouldApplyWindShader()) {
return;
}
for (const uniforms of this.windUniforms) {
writeFoliageWindUniformValues(uniforms, this.quality, this.windTime);
}
}
private getWindMaterialOptions(): FoliageWindMaterialOptions | undefined {
if (!this.shouldApplyWindShader()) {
return undefined;
}
return {
getSettings: () => this.quality,
getTime: () => this.windTime,
registerUniforms: this.registerWindUniforms
};
}
private loadTemplateSourceMeshes(
bundledPath: string
): Promise<FoliageTemplateSourceMesh[]> {
@@ -597,8 +816,12 @@ export class FoliageInstancedRenderer {
batchGroup.userData.foliageLayerId = batch.layerId;
batchGroup.userData.foliageTerrainId = batch.terrainId;
const windOptions = this.getWindMaterialOptions();
for (const sourceMesh of sourceMeshes) {
batchGroup.add(createInstancedMeshForSource(batch, sourceMesh));
batchGroup.add(
createInstancedMeshForSource(batch, sourceMesh, windOptions)
);
}
applyRendererRenderCategoryFromMaterial(batchGroup);

View File

@@ -37,11 +37,7 @@ export interface DerivedTerrainMeshData {
export const TERRAIN_LOD_CHUNK_SIZE_CELLS = 64;
export const TERRAIN_LOD_STRIDES = [1, 2, 4, 8, 16] as const;
export const TERRAIN_LOD_DEBUG_COLORS = [
0xff4d4d,
0xffa53d,
0xffe66d,
0x4ee06f,
0x4ba3ff
0xff4d4d, 0xffa53d, 0xffe66d, 0x4ee06f, 0x4ba3ff
] as const;
const TERRAIN_LOD_DISTANCE_MULTIPLIERS = [0.75, 1.5, 3, 6] as const;
const TERRAIN_LOD_HYSTERESIS_RATIO = 0.16;
@@ -51,6 +47,11 @@ interface TerrainMeshBuildOptions {
foliageBlockerMask?: boolean;
}
interface TerrainLodSamplePoint {
sampleX: number;
sampleZ: number;
}
export interface TerrainLodLevelMeshData {
level: number;
stride: number;
@@ -227,7 +228,9 @@ export function buildTerrainDerivedMeshData(
const vertexCount = terrain.sampleCountX * terrain.sampleCountZ;
const positions = new Float32Array(vertexCount * 3);
const uvs = new Float32Array(vertexCount * 2);
const layerWeights = new Float32Array(vertexCount * TERRAIN_SHADER_LAYER_COUNT);
const layerWeights = new Float32Array(
vertexCount * TERRAIN_SHADER_LAYER_COUNT
);
const foliageMaskWeights = new Float32Array(vertexCount);
const foliageMask =
options.foliageMaskLayerId === undefined ||
@@ -284,11 +287,7 @@ export function buildTerrainDerivedMeshData(
)
: foliageMask === null
? 0
: getTerrainFoliageMaskValueAtSample(
foliageMask,
sampleX,
sampleZ
);
: getTerrainFoliageMaskValueAtSample(foliageMask, sampleX, sampleZ);
foliageMaskWeightOffset += 1;
}
}
@@ -420,6 +419,179 @@ function buildTerrainChunkSourceBounds(
return localBounds;
}
function clampSampleCoordinate(value: number, maxSample: number): number {
return Math.min(maxSample, Math.max(0, value));
}
function interpolateScalar(
topLeft: number,
topRight: number,
bottomLeft: number,
bottomRight: number,
tx: number,
tz: number
): number {
const top = topLeft + (topRight - topLeft) * tx;
const bottom = bottomLeft + (bottomRight - bottomLeft) * tx;
return top + (bottom - top) * tz;
}
function sampleTerrainScalarAtSamplePosition(
terrain: Pick<Terrain, "sampleCountX" | "sampleCountZ">,
sampleX: number,
sampleZ: number,
sampleValue: (sampleX: number, sampleZ: number) => number
): number {
const clampedSampleX = clampSampleCoordinate(
sampleX,
terrain.sampleCountX - 1
);
const clampedSampleZ = clampSampleCoordinate(
sampleZ,
terrain.sampleCountZ - 1
);
const sampleX0 = Math.floor(clampedSampleX);
const sampleZ0 = Math.floor(clampedSampleZ);
const sampleX1 = Math.min(sampleX0 + 1, terrain.sampleCountX - 1);
const sampleZ1 = Math.min(sampleZ0 + 1, terrain.sampleCountZ - 1);
const tx = sampleX1 === sampleX0 ? 0 : clampedSampleX - sampleX0;
const tz = sampleZ1 === sampleZ0 ? 0 : clampedSampleZ - sampleZ0;
return interpolateScalar(
sampleValue(sampleX0, sampleZ0),
sampleValue(sampleX1, sampleZ0),
sampleValue(sampleX0, sampleZ1),
sampleValue(sampleX1, sampleZ1),
tx,
tz
);
}
function sampleTerrainHeightAtSamplePosition(
terrain: Terrain,
sampleX: number,
sampleZ: number
): number {
return sampleTerrainScalarAtSamplePosition(
terrain,
sampleX,
sampleZ,
(x, z) => getTerrainHeightAtSample(terrain, x, z)
);
}
function sampleTerrainLayerWeightsAtSamplePosition(
terrain: Terrain,
sampleX: number,
sampleZ: number
): number[] {
const layerWeights = new Array<number>(terrain.layers.length).fill(0);
for (
let layerIndex = 0;
layerIndex < terrain.layers.length;
layerIndex += 1
) {
layerWeights[layerIndex] = sampleTerrainScalarAtSamplePosition(
terrain,
sampleX,
sampleZ,
(x, z) => getTerrainSampleLayerWeights(terrain, x, z)[layerIndex] ?? 0
);
}
return layerWeights;
}
function sampleTerrainFoliageWeightAtSamplePosition(
terrain: Terrain,
sampleX: number,
sampleZ: number,
options: TerrainMeshBuildOptions
): number {
const foliageMask =
options.foliageMaskLayerId === undefined ||
options.foliageMaskLayerId === null
? null
: getTerrainFoliageMask(terrain, options.foliageMaskLayerId);
if (options.foliageBlockerMask === true) {
return sampleTerrainScalarAtSamplePosition(
terrain,
sampleX,
sampleZ,
(x, z) =>
getTerrainFoliageBlockerMaskValueAtSample(
terrain.foliageBlockerMask,
x,
z
)
);
}
if (foliageMask === null) {
return 0;
}
return sampleTerrainScalarAtSamplePosition(
terrain,
sampleX,
sampleZ,
(x, z) => getTerrainFoliageMaskValueAtSample(foliageMask, x, z)
);
}
function pushTerrainLodNormal(
terrain: Terrain,
sampleX: number,
sampleZ: number,
normals: number[]
) {
const leftSampleX = clampSampleCoordinate(
sampleX - 1,
terrain.sampleCountX - 1
);
const rightSampleX = clampSampleCoordinate(
sampleX + 1,
terrain.sampleCountX - 1
);
const bottomSampleZ = clampSampleCoordinate(
sampleZ - 1,
terrain.sampleCountZ - 1
);
const topSampleZ = clampSampleCoordinate(
sampleZ + 1,
terrain.sampleCountZ - 1
);
const dxDenominator = Math.max(
(rightSampleX - leftSampleX) * terrain.cellSize,
Number.EPSILON
);
const dzDenominator = Math.max(
(topSampleZ - bottomSampleZ) * terrain.cellSize,
Number.EPSILON
);
const slopeX =
(sampleTerrainHeightAtSamplePosition(terrain, rightSampleX, sampleZ) -
sampleTerrainHeightAtSamplePosition(terrain, leftSampleX, sampleZ)) /
dxDenominator;
const slopeZ =
(sampleTerrainHeightAtSamplePosition(terrain, sampleX, topSampleZ) -
sampleTerrainHeightAtSamplePosition(terrain, sampleX, bottomSampleZ)) /
dzDenominator;
const normalX = -slopeX;
const normalY = 1;
const normalZ = -slopeZ;
const normalLength = Math.hypot(normalX, normalY, normalZ) || 1;
normals.push(
normalX / normalLength,
normalY / normalLength,
normalZ / normalLength
);
}
function pushTerrainLodVertex(
terrain: Terrain,
sampleX: number,
@@ -429,12 +601,14 @@ function pushTerrainLodVertex(
uvs: number[],
layerWeights: number[],
foliageMaskWeights: number[],
normals: number[],
options: TerrainMeshBuildOptions
) {
const localX = sampleX * terrain.cellSize;
const localY = getTerrainHeightAtSample(terrain, sampleX, sampleZ) + yOffset;
const localY =
sampleTerrainHeightAtSamplePosition(terrain, sampleX, sampleZ) + yOffset;
const localZ = sampleZ * terrain.cellSize;
const sampleLayerWeights = getTerrainSampleLayerWeights(
const sampleLayerWeights = sampleTerrainLayerWeightsAtSamplePosition(
terrain,
sampleX,
sampleZ
@@ -444,23 +618,15 @@ function pushTerrainLodVertex(
uvs.push(terrain.position.x + localX, terrain.position.z + localZ);
pushPaddedTerrainLayerWeights(layerWeights, sampleLayerWeights);
const foliageMask =
options.foliageMaskLayerId === undefined ||
options.foliageMaskLayerId === null
? null
: getTerrainFoliageMask(terrain, options.foliageMaskLayerId);
foliageMaskWeights.push(
options.foliageBlockerMask === true
? getTerrainFoliageBlockerMaskValueAtSample(
terrain.foliageBlockerMask,
sampleX,
sampleZ
)
: foliageMask === null
? 0
: getTerrainFoliageMaskValueAtSample(foliageMask, sampleX, sampleZ)
sampleTerrainFoliageWeightAtSamplePosition(
terrain,
sampleX,
sampleZ,
options
)
);
pushTerrainLodNormal(terrain, sampleX, sampleZ, normals);
}
function pushTerrainLodSkirtSegment(
@@ -474,6 +640,7 @@ function pushTerrainLodSkirtSegment(
uvs: number[],
layerWeights: number[],
foliageMaskWeights: number[],
normals: number[],
indices: number[],
options: TerrainMeshBuildOptions
) {
@@ -487,6 +654,7 @@ function pushTerrainLodSkirtSegment(
uvs,
layerWeights,
foliageMaskWeights,
normals,
options
);
const topEnd = positions.length / 3;
@@ -499,6 +667,7 @@ function pushTerrainLodSkirtSegment(
uvs,
layerWeights,
foliageMaskWeights,
normals,
options
);
const bottomStart = positions.length / 3;
@@ -511,6 +680,7 @@ function pushTerrainLodSkirtSegment(
uvs,
layerWeights,
foliageMaskWeights,
normals,
options
);
const bottomEnd = positions.length / 3;
@@ -523,6 +693,7 @@ function pushTerrainLodSkirtSegment(
uvs,
layerWeights,
foliageMaskWeights,
normals,
options
);
@@ -530,6 +701,149 @@ function pushTerrainLodSkirtSegment(
indices.push(topStart, bottomEnd, topEnd);
}
function createTerrainLodVertexKey(
sampleX: number,
sampleZ: number,
yOffset: number
): string {
return `${sampleX.toFixed(6)}:${sampleZ.toFixed(6)}:${yOffset.toFixed(6)}`;
}
function getOrCreateTerrainLodVertex(
terrain: Terrain,
sampleX: number,
sampleZ: number,
yOffset: number,
vertexIndices: Map<string, number>,
positions: number[],
uvs: number[],
layerWeights: number[],
foliageMaskWeights: number[],
normals: number[],
options: TerrainMeshBuildOptions
): number {
const key = createTerrainLodVertexKey(sampleX, sampleZ, yOffset);
const existingIndex = vertexIndices.get(key);
if (existingIndex !== undefined) {
return existingIndex;
}
const nextIndex = positions.length / 3;
pushTerrainLodVertex(
terrain,
sampleX,
sampleZ,
yOffset,
positions,
uvs,
layerWeights,
foliageMaskWeights,
normals,
options
);
vertexIndices.set(key, nextIndex);
return nextIndex;
}
function areTerrainLodSamplePointsEqual(
left: TerrainLodSamplePoint,
right: TerrainLodSamplePoint
): boolean {
return left.sampleX === right.sampleX && left.sampleZ === right.sampleZ;
}
function pushTerrainLodBoundaryPoint(
points: TerrainLodSamplePoint[],
point: TerrainLodSamplePoint
) {
const previous = points[points.length - 1];
if (
previous !== undefined &&
areTerrainLodSamplePointsEqual(previous, point)
) {
return;
}
points.push(point);
}
function pushTerrainLodBoundarySegment(
points: TerrainLodSamplePoint[],
from: TerrainLodSamplePoint,
to: TerrainLodSamplePoint,
dense: boolean
) {
const deltaX = to.sampleX - from.sampleX;
const deltaZ = to.sampleZ - from.sampleZ;
const stepCount = dense ? Math.max(Math.abs(deltaX), Math.abs(deltaZ)) : 1;
if (points.length === 0) {
pushTerrainLodBoundaryPoint(points, from);
}
for (let step = 1; step <= stepCount; step += 1) {
const t = step / stepCount;
pushTerrainLodBoundaryPoint(points, {
sampleX: from.sampleX + deltaX * t,
sampleZ: from.sampleZ + deltaZ * t
});
}
}
function createTerrainLodCellBoundaryPoints(
startSampleX: number,
startSampleZ: number,
endSampleX: number,
endSampleZ: number,
cellStartSampleX: number,
cellStartSampleZ: number,
cellEndSampleX: number,
cellEndSampleZ: number
): TerrainLodSamplePoint[] {
const points: TerrainLodSamplePoint[] = [];
pushTerrainLodBoundarySegment(
points,
{ sampleX: cellStartSampleX, sampleZ: cellStartSampleZ },
{ sampleX: cellStartSampleX, sampleZ: cellEndSampleZ },
cellStartSampleX === startSampleX
);
pushTerrainLodBoundarySegment(
points,
{ sampleX: cellStartSampleX, sampleZ: cellEndSampleZ },
{ sampleX: cellEndSampleX, sampleZ: cellEndSampleZ },
cellEndSampleZ === endSampleZ
);
pushTerrainLodBoundarySegment(
points,
{ sampleX: cellEndSampleX, sampleZ: cellEndSampleZ },
{ sampleX: cellEndSampleX, sampleZ: cellStartSampleZ },
cellEndSampleX === endSampleX
);
pushTerrainLodBoundarySegment(
points,
{ sampleX: cellEndSampleX, sampleZ: cellStartSampleZ },
{ sampleX: cellStartSampleX, sampleZ: cellStartSampleZ },
cellStartSampleZ === startSampleZ
);
const first = points[0];
const last = points[points.length - 1];
if (
first !== undefined &&
last !== undefined &&
areTerrainLodSamplePointsEqual(first, last)
) {
points.pop();
}
return points;
}
function buildTerrainLodLevelMeshData(
terrain: Terrain,
startSampleX: number,
@@ -540,50 +854,141 @@ function buildTerrainLodLevelMeshData(
stride: number,
options: TerrainMeshBuildOptions
): TerrainLodLevelMeshData {
const sampleXs = createLodSampleCoordinates(
startSampleX,
endSampleX,
stride
);
const sampleZs = createLodSampleCoordinates(
startSampleZ,
endSampleZ,
stride
);
const sampleXs = createLodSampleCoordinates(startSampleX, endSampleX, stride);
const sampleZs = createLodSampleCoordinates(startSampleZ, endSampleZ, stride);
const positions: number[] = [];
const uvs: number[] = [];
const layerWeights: number[] = [];
const foliageMaskWeights: number[] = [];
const normals: number[] = [];
const indices: number[] = [];
for (const sampleZ of sampleZs) {
for (const sampleX of sampleXs) {
pushTerrainLodVertex(
terrain,
sampleX,
sampleZ,
0,
positions,
uvs,
layerWeights,
foliageMaskWeights,
options
);
}
}
const sampleCountX = sampleXs.length;
const vertexIndices = new Map<string, number>();
for (let zIndex = 0; zIndex < sampleZs.length - 1; zIndex += 1) {
for (let xIndex = 0; xIndex < sampleXs.length - 1; xIndex += 1) {
const topLeft = zIndex * sampleCountX + xIndex;
const topRight = topLeft + 1;
const bottomLeft = (zIndex + 1) * sampleCountX + xIndex;
const bottomRight = bottomLeft + 1;
const sampleX = sampleXs[xIndex]!;
const nextSampleX = sampleXs[xIndex + 1]!;
const sampleZ = sampleZs[zIndex]!;
const nextSampleZ = sampleZs[zIndex + 1]!;
const boundaryPoints = createTerrainLodCellBoundaryPoints(
startSampleX,
startSampleZ,
endSampleX,
endSampleZ,
sampleX,
sampleZ,
nextSampleX,
nextSampleZ
);
if (boundaryPoints.length > 4) {
const centerIndex = getOrCreateTerrainLodVertex(
terrain,
(sampleX + nextSampleX) * 0.5,
(sampleZ + nextSampleZ) * 0.5,
0,
vertexIndices,
positions,
uvs,
layerWeights,
foliageMaskWeights,
normals,
options
);
for (
let boundaryIndex = 0;
boundaryIndex < boundaryPoints.length;
boundaryIndex += 1
) {
const currentPoint = boundaryPoints[boundaryIndex]!;
const nextPoint =
boundaryPoints[(boundaryIndex + 1) % boundaryPoints.length]!;
const currentIndex = getOrCreateTerrainLodVertex(
terrain,
currentPoint.sampleX,
currentPoint.sampleZ,
0,
vertexIndices,
positions,
uvs,
layerWeights,
foliageMaskWeights,
normals,
options
);
const nextIndex = getOrCreateTerrainLodVertex(
terrain,
nextPoint.sampleX,
nextPoint.sampleZ,
0,
vertexIndices,
positions,
uvs,
layerWeights,
foliageMaskWeights,
normals,
options
);
indices.push(centerIndex, currentIndex, nextIndex);
}
continue;
}
const topLeft = getOrCreateTerrainLodVertex(
terrain,
sampleX,
sampleZ,
0,
vertexIndices,
positions,
uvs,
layerWeights,
foliageMaskWeights,
normals,
options
);
const topRight = getOrCreateTerrainLodVertex(
terrain,
nextSampleX,
sampleZ,
0,
vertexIndices,
positions,
uvs,
layerWeights,
foliageMaskWeights,
normals,
options
);
const bottomLeft = getOrCreateTerrainLodVertex(
terrain,
sampleX,
nextSampleZ,
0,
vertexIndices,
positions,
uvs,
layerWeights,
foliageMaskWeights,
normals,
options
);
const bottomRight = getOrCreateTerrainLodVertex(
terrain,
nextSampleX,
nextSampleZ,
0,
vertexIndices,
positions,
uvs,
layerWeights,
foliageMaskWeights,
normals,
options
);
const diagonal = chooseCellDiagonal(
getTerrainHeightAtSample(terrain, sampleX, sampleZ),
getTerrainHeightAtSample(terrain, nextSampleX, sampleZ),
@@ -603,77 +1008,99 @@ function buildTerrainLodLevelMeshData(
const skirtStartVertexCount = positions.length / 3;
const skirtDepth = Math.max(terrain.cellSize * stride * 1.5, 0.5);
const terrainEndSampleX = terrain.sampleCountX - 1;
const terrainEndSampleZ = terrain.sampleCountZ - 1;
for (let xIndex = 0; xIndex < sampleXs.length - 1; xIndex += 1) {
pushTerrainLodSkirtSegment(
terrain,
sampleXs[xIndex]!,
startSampleZ,
sampleXs[xIndex + 1]!,
startSampleZ,
skirtDepth,
positions,
uvs,
layerWeights,
foliageMaskWeights,
indices,
options
);
pushTerrainLodSkirtSegment(
terrain,
sampleXs[xIndex + 1]!,
endSampleZ,
sampleXs[xIndex]!,
endSampleZ,
skirtDepth,
positions,
uvs,
layerWeights,
foliageMaskWeights,
indices,
options
);
if (startSampleZ === 0) {
for (let sampleX = startSampleX; sampleX < endSampleX; sampleX += 1) {
pushTerrainLodSkirtSegment(
terrain,
sampleX,
startSampleZ,
sampleX + 1,
startSampleZ,
skirtDepth,
positions,
uvs,
layerWeights,
foliageMaskWeights,
normals,
indices,
options
);
}
}
for (let zIndex = 0; zIndex < sampleZs.length - 1; zIndex += 1) {
pushTerrainLodSkirtSegment(
terrain,
startSampleX,
sampleZs[zIndex + 1]!,
startSampleX,
sampleZs[zIndex]!,
skirtDepth,
positions,
uvs,
layerWeights,
foliageMaskWeights,
indices,
options
);
pushTerrainLodSkirtSegment(
terrain,
endSampleX,
sampleZs[zIndex]!,
endSampleX,
sampleZs[zIndex + 1]!,
skirtDepth,
positions,
uvs,
layerWeights,
foliageMaskWeights,
indices,
options
);
if (endSampleZ === terrainEndSampleZ) {
for (let sampleX = startSampleX; sampleX < endSampleX; sampleX += 1) {
pushTerrainLodSkirtSegment(
terrain,
sampleX + 1,
endSampleZ,
sampleX,
endSampleZ,
skirtDepth,
positions,
uvs,
layerWeights,
foliageMaskWeights,
normals,
indices,
options
);
}
}
if (startSampleX === 0) {
for (let sampleZ = startSampleZ; sampleZ < endSampleZ; sampleZ += 1) {
pushTerrainLodSkirtSegment(
terrain,
startSampleX,
sampleZ + 1,
startSampleX,
sampleZ,
skirtDepth,
positions,
uvs,
layerWeights,
foliageMaskWeights,
normals,
indices,
options
);
}
}
if (endSampleX === terrainEndSampleX) {
for (let sampleZ = startSampleZ; sampleZ < endSampleZ; sampleZ += 1) {
pushTerrainLodSkirtSegment(
terrain,
endSampleX,
sampleZ,
endSampleX,
sampleZ + 1,
skirtDepth,
positions,
uvs,
layerWeights,
foliageMaskWeights,
normals,
indices,
options
);
}
}
const typedPositions = new Float32Array(positions);
const typedUvs = new Float32Array(uvs);
const typedLayerWeights = new Float32Array(layerWeights);
const typedFoliageMaskWeights = new Float32Array(foliageMaskWeights);
const typedNormals = new Float32Array(normals);
const typedIndices = new Uint32Array(indices);
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(typedPositions, 3));
geometry.setAttribute("normal", new BufferAttribute(typedNormals, 3));
geometry.setAttribute("uv", new BufferAttribute(typedUvs, 2));
setTerrainLayerWeightAttributes(geometry, typedLayerWeights);
geometry.setAttribute(
@@ -681,20 +1108,15 @@ function buildTerrainLodLevelMeshData(
new BufferAttribute(typedFoliageMaskWeights, 1)
);
geometry.setIndex(new BufferAttribute(typedIndices, 1));
geometry.computeVertexNormals();
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
const normalAttribute = geometry.getAttribute("normal");
const normals = new Float32Array(normalAttribute.array.length);
normals.set(normalAttribute.array as ArrayLike<number>);
return {
level,
stride,
geometry,
positions: typedPositions,
normals,
normals: typedNormals,
uvs: typedUvs,
layerWeights: typedLayerWeights,
foliageMaskWeights: typedFoliageMaskWeights,
@@ -887,29 +1309,23 @@ export function resolveTerrainLodLevelIndexWithHysteresis(options: {
const lowerBoundary =
activeLevelIndex <= 0
? Number.NEGATIVE_INFINITY
: TERRAIN_LOD_DISTANCE_MULTIPLIERS[activeLevelIndex - 1] ??
: (TERRAIN_LOD_DISTANCE_MULTIPLIERS[activeLevelIndex - 1] ??
TERRAIN_LOD_DISTANCE_MULTIPLIERS[
TERRAIN_LOD_DISTANCE_MULTIPLIERS.length - 1
]!;
]!);
const upperBoundary =
activeLevelIndex >= options.levelCount - 1
? Number.POSITIVE_INFINITY
: TERRAIN_LOD_DISTANCE_MULTIPLIERS[activeLevelIndex] ??
: (TERRAIN_LOD_DISTANCE_MULTIPLIERS[activeLevelIndex] ??
TERRAIN_LOD_DISTANCE_MULTIPLIERS[
TERRAIN_LOD_DISTANCE_MULTIPLIERS.length - 1
]!;
]!);
if (
normalizedDistance >
upperBoundary * (1 + TERRAIN_LOD_HYSTERESIS_RATIO)
) {
if (normalizedDistance > upperBoundary * (1 + TERRAIN_LOD_HYSTERESIS_RATIO)) {
return Math.min(activeLevelIndex + 1, options.levelCount - 1);
}
if (
normalizedDistance <
lowerBoundary * (1 - TERRAIN_LOD_HYSTERESIS_RATIO)
) {
if (normalizedDistance < lowerBoundary * (1 - TERRAIN_LOD_HYSTERESIS_RATIO)) {
return Math.max(activeLevelIndex - 1, 0);
}

View File

@@ -28,6 +28,7 @@ import {
DepthOfFieldEffect,
EffectComposer,
EffectPass,
FXAAEffect,
NormalPass,
RenderPass,
ShaderPass,
@@ -40,6 +41,7 @@ import {
import type {
AdvancedRenderingSettings,
AdvancedRenderingAntiAliasingMode,
BoxVolumeRenderPath,
AdvancedRenderingShadowType,
AdvancedRenderingToneMappingMode
@@ -67,6 +69,12 @@ import {
shouldApplyGodRays,
type ScreenSpaceGodRaysLightSource
} from "./screen-space-god-rays";
import {
ScreenSpaceLensFlarePass,
resolveLensFlareParameters,
shouldApplyLensFlare,
type ScreenSpaceLensFlareLightSource
} from "./screen-space-lens-flare";
const AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE = 0.15;
const MIN_AMBIENT_OCCLUSION_EFFECT_RADIUS = 0.02;
@@ -240,6 +248,48 @@ export function getAdvancedRenderingToneMappingMode(
}
}
function getAdvancedRenderingAntiAliasingMultisampling(
renderer: WebGLRenderer,
settings: AdvancedRenderingSettings
) {
if (
!settings.enabled ||
!settings.antiAliasing.enabled ||
!renderer.capabilities.isWebGL2
) {
return 0;
}
switch (settings.antiAliasing.mode) {
case "msaa2x":
return 2;
case "msaa4x":
return 4;
case "msaa8x":
return 8;
case "smaa":
case "fxaa":
return 0;
}
}
function createAdvancedRenderingAntiAliasingEffect(
mode: AdvancedRenderingAntiAliasingMode
): FXAAEffect | SMAAEffect | null {
switch (mode) {
case "smaa":
return new SMAAEffect({
preset: SMAAPreset.MEDIUM
});
case "fxaa":
return new FXAAEffect();
case "msaa2x":
case "msaa4x":
case "msaa8x":
return null;
}
}
export function configureAdvancedRenderingRenderer(
renderer: WebGLRenderer,
settings: AdvancedRenderingSettings
@@ -316,14 +366,18 @@ export function createAdvancedRenderingComposer(
settings: AdvancedRenderingSettings,
backgroundScene: Scene | null = null,
godRaysLightSource: ScreenSpaceGodRaysLightSource | null = null,
distanceFogSkyColorSource: DistanceFogSkyColorSource | null = null
distanceFogSkyColorSource: DistanceFogSkyColorSource | null = null,
lensFlareLightSource: ScreenSpaceLensFlareLightSource | null = null
): EffectComposer {
// The scene is always rendered into the composer's offscreen targets first,
// so those targets need depth for correct visibility even when no effect samples it.
const composer = new EffectComposer(renderer, {
depthBuffer: true,
stencilBuffer: false,
multisampling: 0,
multisampling: getAdvancedRenderingAntiAliasingMultisampling(
renderer,
settings
),
frameBufferType: renderer.capabilities.isWebGL2
? HalfFloatType
: UnsignedByteType
@@ -342,11 +396,15 @@ export function createAdvancedRenderingComposer(
const godRaysParameters = resolveGodRaysParameters(settings.godRays);
const godRaysEnabled =
shouldApplyGodRays(settings) && godRaysLightSource !== null;
const lensFlareParameters = resolveLensFlareParameters(settings.lensFlare);
const lensFlareEnabled =
shouldApplyLensFlare(settings) && lensFlareLightSource !== null;
const postWorldLayerIsolationEnabled =
settings.ambientOcclusion.enabled ||
dynamicGlobalIlluminationEnabled ||
distanceFogEnabled ||
godRaysEnabled;
godRaysEnabled ||
lensFlareEnabled;
const mainRenderLayerMask = postWorldLayerIsolationEnabled
? AO_WORLD_RENDER_LAYER_MASK
: ALL_RENDER_LAYER_MASK;
@@ -365,7 +423,11 @@ export function createAdvancedRenderingComposer(
}
const effects: Array<
BloomEffect | DepthOfFieldEffect | ToneMappingEffect | SMAAEffect
| BloomEffect
| DepthOfFieldEffect
| FXAAEffect
| ToneMappingEffect
| SMAAEffect
> = [];
if (settings.ambientOcclusion.enabled || dynamicGlobalIlluminationEnabled) {
@@ -460,6 +522,16 @@ export function createAdvancedRenderingComposer(
);
}
if (lensFlareEnabled && lensFlareLightSource !== null) {
composer.addPass(
new ScreenSpaceLensFlarePass(
camera,
lensFlareLightSource,
lensFlareParameters
)
);
}
if (postWorldLayerIsolationEnabled) {
composer.addPass(
createPostAmbientOcclusionRenderPass(
@@ -502,11 +574,15 @@ export function createAdvancedRenderingComposer(
mode: getAdvancedRenderingToneMappingMode(settings.toneMapping.mode)
})
);
effects.push(
new SMAAEffect({
preset: SMAAPreset.MEDIUM
})
);
if (settings.antiAliasing.enabled) {
const antiAliasingEffect = createAdvancedRenderingAntiAliasingEffect(
settings.antiAliasing.mode
);
if (antiAliasingEffect !== null) {
effects.push(antiAliasingEffect);
}
}
composer.addPass(new EffectPass(camera, ...effects));

View File

@@ -0,0 +1,448 @@
import {
BasicDepthPacking,
Color,
ShaderMaterial,
Texture,
Uniform,
Vector2,
Vector3,
type DepthPackingStrategies,
type PerspectiveCamera,
type WebGLRenderTarget,
type WebGLRenderer
} from "three";
import { Pass } from "postprocessing";
import type { Vec3 } from "../core/vector";
import type {
AdvancedRenderingLensFlareSettings,
AdvancedRenderingSettings
} from "../document/world-settings";
const MIN_CELESTIAL_LIGHT_INTENSITY = 1e-4;
const MAX_LENS_FLARE_INTENSITY = 4;
const MIN_LENS_FLARE_HALO_SIZE = 0.25;
const MAX_LENS_FLARE_HALO_SIZE = 3;
const MAX_LENS_FLARE_GHOST_INTENSITY = 3;
const MIN_LENS_FLARE_GHOST_COUNT = 1;
const MAX_LENS_FLARE_GHOST_COUNT = 8;
const LIGHT_OFFSCREEN_FADE_START = 0.92;
const LIGHT_OFFSCREEN_FADE_END = 1;
export interface ResolvedLensFlareParameters {
enabled: boolean;
intensity: number;
haloSize: number;
ghostIntensity: number;
ghostCount: number;
}
export interface ScreenSpaceLensFlareLightSource {
direction: Vec3 | null;
colorHex: string;
intensity: number;
}
export interface ScreenSpaceLensFlareLightInput {
direction: Vec3;
colorHex: string;
intensity: number;
}
export interface ScreenSpaceLensFlareLightProjection {
screenPosition: {
x: number;
y: number;
};
visibility: number;
}
function clampNumber(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function finiteOr(value: number, fallback: number) {
return Number.isFinite(value) ? value : fallback;
}
function smoothstep(edge0: number, edge1: number, value: number) {
const t = clampNumber((value - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
function formatGlslFloat(value: number): string {
return value.toFixed(4);
}
function isFiniteVec3(vector: Vec3 | null): vector is Vec3 {
return (
vector !== null &&
Number.isFinite(vector.x) &&
Number.isFinite(vector.y) &&
Number.isFinite(vector.z)
);
}
export function createScreenSpaceLensFlareLightSource(): ScreenSpaceLensFlareLightSource {
return {
direction: null,
colorHex: "#ffffff",
intensity: 0
};
}
export function syncScreenSpaceLensFlareLightSource(
target: ScreenSpaceLensFlareLightSource,
light: ScreenSpaceLensFlareLightInput | null
) {
if (
light === null ||
light.intensity <= MIN_CELESTIAL_LIGHT_INTENSITY ||
!isFiniteVec3(light.direction)
) {
target.direction = null;
target.colorHex = "#ffffff";
target.intensity = 0;
return;
}
target.direction = {
x: light.direction.x,
y: light.direction.y,
z: light.direction.z
};
target.colorHex = light.colorHex;
target.intensity = light.intensity;
}
export function resolveLensFlareParameters(
settings: AdvancedRenderingLensFlareSettings
): ResolvedLensFlareParameters {
const intensity = clampNumber(
finiteOr(settings.intensity, 0),
0,
MAX_LENS_FLARE_INTENSITY
);
const haloSize = clampNumber(
finiteOr(settings.haloSize, 1),
MIN_LENS_FLARE_HALO_SIZE,
MAX_LENS_FLARE_HALO_SIZE
);
const ghostIntensity = clampNumber(
finiteOr(settings.ghostIntensity, 0),
0,
MAX_LENS_FLARE_GHOST_INTENSITY
);
const ghostCount = Math.round(
clampNumber(
finiteOr(settings.ghostCount, MIN_LENS_FLARE_GHOST_COUNT),
MIN_LENS_FLARE_GHOST_COUNT,
MAX_LENS_FLARE_GHOST_COUNT
)
);
return {
enabled: settings.enabled && intensity > 0 && haloSize > 0,
intensity,
haloSize,
ghostIntensity,
ghostCount
};
}
export function shouldApplyLensFlare(settings: AdvancedRenderingSettings) {
return (
settings.enabled && resolveLensFlareParameters(settings.lensFlare).enabled
);
}
export function projectScreenSpaceLensFlareLight(
camera: PerspectiveCamera,
lightSource: ScreenSpaceLensFlareLightSource
): ScreenSpaceLensFlareLightProjection | null {
if (
lightSource.intensity <= MIN_CELESTIAL_LIGHT_INTENSITY ||
!isFiniteVec3(lightSource.direction)
) {
return null;
}
const direction = new Vector3(
lightSource.direction.x,
lightSource.direction.y,
lightSource.direction.z
);
if (direction.lengthSq() <= 1e-8) {
return null;
}
direction.normalize();
camera.updateMatrixWorld();
camera.updateProjectionMatrix();
const cameraPosition = new Vector3().setFromMatrixPosition(
camera.matrixWorld
);
const projectionDistance = Math.max(
camera.near + 1,
Math.min(camera.far * 0.5, 500)
);
const worldPosition = cameraPosition
.clone()
.add(direction.multiplyScalar(projectionDistance));
const viewPosition = worldPosition.clone().applyMatrix4(
camera.matrixWorldInverse
);
if (viewPosition.z >= -camera.near) {
return null;
}
const ndcPosition = worldPosition.clone().project(camera);
if (!Number.isFinite(ndcPosition.x) || !Number.isFinite(ndcPosition.y)) {
return null;
}
const maxAxisDistance = Math.max(
Math.abs(ndcPosition.x),
Math.abs(ndcPosition.y)
);
const visibility =
1 -
smoothstep(
LIGHT_OFFSCREEN_FADE_START,
LIGHT_OFFSCREEN_FADE_END,
maxAxisDistance
);
if (visibility <= 0) {
return null;
}
return {
screenPosition: {
x: ndcPosition.x * 0.5 + 0.5,
y: ndcPosition.y * 0.5 + 0.5
},
visibility
};
}
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 1.0, 1.0);
}
`;
const fragmentShader = `
#include <packing>
#define MAX_LENS_FLARE_GHOSTS 8
uniform sampler2D inputBuffer;
uniform sampler2D depthBuffer;
uniform vec2 resolution;
uniform vec2 lightPosition;
uniform vec3 lightColor;
uniform float sourceIntensity;
uniform float intensity;
uniform float haloSize;
uniform float ghostIntensity;
uniform int ghostCount;
varying vec2 vUv;
const float BACKGROUND_DEPTH_THRESHOLD = 0.9999999;
float readDepth(const in vec2 uv) {
#if DEPTH_PACKING == 3201
return unpackRGBAToDepth(texture2D(depthBuffer, uv));
#else
return texture2D(depthBuffer, uv).r;
#endif
}
float getSourceOcclusion() {
vec2 safeResolution = max(resolution, vec2(1.0));
vec2 pixelSize = 1.0 / safeResolution;
float sampleRadius = max(haloSize * 0.008, 1.5 * max(pixelSize.x, pixelSize.y));
float visibility = 0.0;
visibility += step(BACKGROUND_DEPTH_THRESHOLD, readDepth(clamp(lightPosition, vec2(0.0), vec2(1.0))));
visibility += step(BACKGROUND_DEPTH_THRESHOLD, readDepth(clamp(lightPosition + vec2(sampleRadius, 0.0), vec2(0.0), vec2(1.0))));
visibility += step(BACKGROUND_DEPTH_THRESHOLD, readDepth(clamp(lightPosition - vec2(sampleRadius, 0.0), vec2(0.0), vec2(1.0))));
visibility += step(BACKGROUND_DEPTH_THRESHOLD, readDepth(clamp(lightPosition + vec2(0.0, sampleRadius), vec2(0.0), vec2(1.0))));
visibility += step(BACKGROUND_DEPTH_THRESHOLD, readDepth(clamp(lightPosition - vec2(0.0, sampleRadius), vec2(0.0), vec2(1.0))));
return visibility * 0.2;
}
vec3 getGhostTint(float index) {
vec3 spectral = 0.5 + 0.5 * cos(vec3(0.0, 2.1, 4.2) + index * 1.7);
return mix(lightColor, spectral, 0.35);
}
void main() {
vec4 baseColor = texture2D(inputBuffer, vUv);
if (sourceIntensity <= 0.0 || intensity <= 0.0 || haloSize <= 0.0) {
gl_FragColor = baseColor;
return;
}
vec2 safeResolution = max(resolution, vec2(1.0));
vec2 aspectScale = vec2(safeResolution.x / safeResolution.y, 1.0);
vec2 center = vec2(0.5);
float centerDistance = length((lightPosition - center) * aspectScale);
float viewFocus = 1.0 - smoothstep(0.35, 0.95, centerDistance);
float visibility = getSourceOcclusion() * sourceIntensity * intensity * viewFocus;
if (visibility <= 0.001) {
gl_FragColor = baseColor;
return;
}
vec3 flareColor = vec3(0.0);
float sourceDistance = length((vUv - lightPosition) * aspectScale);
float sourceCore =
1.0 - smoothstep(0.0, haloSize * ${formatGlslFloat(0.032)}, sourceDistance);
float sourceHalo =
1.0 - smoothstep(haloSize * ${formatGlslFloat(0.035)}, haloSize * ${formatGlslFloat(0.24)}, sourceDistance);
float horizontalStreak =
(1.0 - smoothstep(0.0, haloSize * ${formatGlslFloat(0.012)}, abs(vUv.y - lightPosition.y))) *
(1.0 - smoothstep(0.08, 0.58, abs(vUv.x - lightPosition.x)));
flareColor += lightColor * (sourceCore * 1.4 + sourceHalo * 0.42 + horizontalStreak * 0.12);
vec2 flareAxis = center - lightPosition;
for (int ghostIndex = 1; ghostIndex <= MAX_LENS_FLARE_GHOSTS; ++ghostIndex) {
if (ghostIndex > ghostCount) {
break;
}
float ghostProgress = float(ghostIndex) / max(float(ghostCount), 1.0);
vec2 ghostPosition = center + flareAxis * (0.18 + ghostProgress * 1.55);
float ghostRadius = haloSize * mix(0.028, 0.082, ghostProgress);
float ghostDistance = length((vUv - ghostPosition) * aspectScale);
float ghostMask =
1.0 - smoothstep(ghostRadius * 0.25, ghostRadius, ghostDistance);
float ghostFalloff = mix(1.0, 0.45, ghostProgress);
flareColor +=
getGhostTint(float(ghostIndex)) *
ghostMask *
ghostFalloff *
ghostIntensity;
}
gl_FragColor = vec4(baseColor.rgb + flareColor * visibility, baseColor.a);
}
`;
export class ScreenSpaceLensFlarePass extends Pass {
private readonly sourceCamera: PerspectiveCamera;
private readonly lightSource: ScreenSpaceLensFlareLightSource;
private readonly parameters: ResolvedLensFlareParameters;
private readonly material: ShaderMaterial;
private readonly lightPosition = new Vector2(0.5, 0.5);
private readonly lightColor = new Color("#ffffff");
private readonly resolution = new Vector2(1, 1);
constructor(
camera: PerspectiveCamera,
lightSource: ScreenSpaceLensFlareLightSource,
parameters: ResolvedLensFlareParameters
) {
super("ScreenSpaceLensFlarePass");
this.sourceCamera = camera;
this.lightSource = lightSource;
this.parameters = parameters;
this.needsDepthTexture = true;
this.material = new ShaderMaterial({
name: "ScreenSpaceLensFlareMaterial",
defines: {
DEPTH_PACKING: BasicDepthPacking.toFixed(0)
},
uniforms: {
inputBuffer: new Uniform<Texture | null>(null),
depthBuffer: new Uniform<Texture | null>(null),
resolution: new Uniform(this.resolution),
lightPosition: new Uniform(this.lightPosition),
lightColor: new Uniform(this.lightColor),
sourceIntensity: new Uniform(0),
intensity: new Uniform(parameters.intensity),
haloSize: new Uniform(parameters.haloSize),
ghostIntensity: new Uniform(parameters.ghostIntensity),
ghostCount: new Uniform(parameters.ghostCount)
},
vertexShader,
fragmentShader,
depthWrite: false,
depthTest: false
});
this.fullscreenMaterial = this.material;
}
override setDepthTexture(
depthTexture: Texture | null,
depthPacking: DepthPackingStrategies = BasicDepthPacking
) {
this.material.uniforms.depthBuffer.value = depthTexture;
this.material.defines.DEPTH_PACKING = depthPacking.toFixed(0);
this.material.needsUpdate = true;
}
override setSize(width: number, height: number) {
this.resolution.set(Math.max(width, 1), Math.max(height, 1));
}
override render(
renderer: WebGLRenderer,
inputBuffer: WebGLRenderTarget | null,
outputBuffer: WebGLRenderTarget | null
) {
if (inputBuffer === null) {
return;
}
const projection = projectScreenSpaceLensFlareLight(
this.sourceCamera,
this.lightSource
);
const sourceIntensity =
projection === null
? 0
: Math.min(this.lightSource.intensity, 4) * projection.visibility;
if (projection !== null) {
this.lightPosition.set(
projection.screenPosition.x,
projection.screenPosition.y
);
}
this.lightColor.set(this.lightSource.colorHex);
this.material.uniforms.inputBuffer.value = inputBuffer.texture;
this.material.uniforms.sourceIntensity.value = sourceIntensity;
this.material.uniforms.intensity.value = this.parameters.intensity;
this.material.uniforms.haloSize.value = this.parameters.haloSize;
this.material.uniforms.ghostIntensity.value =
this.parameters.ghostIntensity;
this.material.uniforms.ghostCount.value = this.parameters.ghostCount;
renderer.setRenderTarget(this.renderToScreen ? null : outputBuffer);
renderer.render(this.scene, this.camera);
}
override dispose() {
this.material.dispose();
}
}

View File

@@ -41,6 +41,7 @@ import {
createModelInstanceRenderGroup,
disposeModelInstance
} from "../assets/model-instance-rendering";
import { initializeSharedKtx2Loader } from "../assets/ktx2-texture-support";
import { FoliageInstancedRenderer } from "../foliage/foliage-instanced-renderer";
import { SplineRepeaterRenderer } from "../spline-corridor/spline-repeater-renderer";
import type { LoadedModelAsset } from "../assets/gltf-model-import";
@@ -112,6 +113,10 @@ import {
resolveDominantScreenSpaceGodRaysLightInput,
syncScreenSpaceGodRaysLightSource
} from "../rendering/screen-space-god-rays";
import {
createScreenSpaceLensFlareLightSource,
syncScreenSpaceLensFlareLightSource
} from "../rendering/screen-space-lens-flare";
import {
fitCelestialDirectionalShadow,
resolveDominantCelestialShadowCaster
@@ -705,6 +710,8 @@ export class RuntimeHost {
private readonly sunLight = new DirectionalLight();
private readonly moonLight = new DirectionalLight();
private readonly godRaysLightSource = createScreenSpaceGodRaysLightSource();
private readonly lensFlareLightSource =
createScreenSpaceLensFlareLightSource();
private readonly distanceFogSkyColorSource =
createDistanceFogSkyColorSource();
private readonly localLightGroup = new Group();
@@ -964,6 +971,7 @@ export class RuntimeHost {
this.renderer?.domElement ?? document.createElement("canvas");
if (this.renderer !== null) {
initializeSharedKtx2Loader(this.renderer);
this.renderer.setPixelRatio(getRendererPixelRatio());
this.renderer.setClearAlpha(0);
} else {
@@ -3016,58 +3024,64 @@ export class RuntimeHost {
shaderSkyState
)
);
const godRaysLightInput =
const screenSpaceSunLightInput =
shaderSkyState !== null
? resolveDominantScreenSpaceGodRaysLightInput(
shaderSkyState.celestial.sunVisible
? {
colorHex: shaderSkyState.celestial.sunColorHex,
intensity:
shaderSkyState.celestial.sunIntensity *
resolveWorldCelestialHorizonVisibility(
shaderSkyState.celestial.sunDirection.y,
shaderSkyState.sky.horizonHeight
),
direction: shaderSkyState.celestial.sunDirection
}
: null,
shaderSkyState.celestial.moonVisible
? {
colorHex: shaderSkyState.celestial.moonColorHex,
intensity:
shaderSkyState.celestial.moonIntensity *
resolveWorldCelestialHorizonVisibility(
shaderSkyState.celestial.moonDirection.y,
shaderSkyState.sky.horizonHeight
),
direction: shaderSkyState.celestial.moonDirection
}
: null
)
: resolveDominantScreenSpaceGodRaysLightInput(
celestialBodiesState.sun === null
? null
: {
colorHex: celestialBodiesState.sun.colorHex,
direction: celestialBodiesState.sun.direction,
intensity:
celestialBodiesState.sun.intensity *
celestialBodiesState.sun.horizonVisibility
},
celestialBodiesState.moon === null
? null
: {
colorHex: celestialBodiesState.moon.colorHex,
direction: celestialBodiesState.moon.direction,
intensity:
celestialBodiesState.moon.intensity *
celestialBodiesState.moon.horizonVisibility
}
);
? shaderSkyState.celestial.sunVisible
? {
colorHex: shaderSkyState.celestial.sunColorHex,
intensity:
shaderSkyState.celestial.sunIntensity *
resolveWorldCelestialHorizonVisibility(
shaderSkyState.celestial.sunDirection.y,
shaderSkyState.sky.horizonHeight
),
direction: shaderSkyState.celestial.sunDirection
}
: null
: celestialBodiesState.sun === null
? null
: {
colorHex: celestialBodiesState.sun.colorHex,
direction: celestialBodiesState.sun.direction,
intensity:
celestialBodiesState.sun.intensity *
celestialBodiesState.sun.horizonVisibility
};
const screenSpaceMoonLightInput =
shaderSkyState !== null
? shaderSkyState.celestial.moonVisible
? {
colorHex: shaderSkyState.celestial.moonColorHex,
intensity:
shaderSkyState.celestial.moonIntensity *
resolveWorldCelestialHorizonVisibility(
shaderSkyState.celestial.moonDirection.y,
shaderSkyState.sky.horizonHeight
),
direction: shaderSkyState.celestial.moonDirection
}
: null
: celestialBodiesState.moon === null
? null
: {
colorHex: celestialBodiesState.moon.colorHex,
direction: celestialBodiesState.moon.direction,
intensity:
celestialBodiesState.moon.intensity *
celestialBodiesState.moon.horizonVisibility
};
const godRaysLightInput = resolveDominantScreenSpaceGodRaysLightInput(
screenSpaceSunLightInput,
screenSpaceMoonLightInput
);
syncScreenSpaceGodRaysLightSource(
this.godRaysLightSource,
godRaysLightInput
);
syncScreenSpaceLensFlareLightSource(
this.lensFlareLightSource,
screenSpaceSunLightInput
);
const environmentState = resolveWorldEnvironmentState(
resolvedWorld.background,
backgroundTexture,
@@ -3195,7 +3209,8 @@ export class RuntimeHost {
settings,
this.worldBackgroundRenderer.scene,
this.godRaysLightSource,
this.distanceFogSkyColorSource
this.distanceFogSkyColorSource,
this.lensFlareLightSource
);
this.currentAdvancedRenderingSettings =
cloneAdvancedRenderingSettings(settings);
@@ -5689,6 +5704,7 @@ export class RuntimeHost {
this.updateTerrainLodVisibility();
this.foliageRenderer.updateView(this.camera);
this.foliageRenderer.updateWind(simulationDt);
this.updateUnderwaterSceneFog();
this.syncCelestialShadowState();

View File

@@ -93,6 +93,7 @@ import {
disposeModelInstance,
syncModelInstanceSelectionShell
} from "../assets/model-instance-rendering";
import { initializeSharedKtx2Loader } from "../assets/ktx2-texture-support";
import { FoliageInstancedRenderer } from "../foliage/foliage-instanced-renderer";
import { SplineRepeaterRenderer } from "../spline-corridor/spline-repeater-renderer";
import type { LoadedModelAsset } from "../assets/gltf-model-import";
@@ -263,6 +264,10 @@ import {
resolveDominantScreenSpaceGodRaysLightInput,
syncScreenSpaceGodRaysLightSource
} from "../rendering/screen-space-god-rays";
import {
createScreenSpaceLensFlareLightSource,
syncScreenSpaceLensFlareLightSource
} from "../rendering/screen-space-lens-flare";
import {
fitCelestialDirectionalShadow,
resolveDominantCelestialShadowCaster
@@ -1061,6 +1066,8 @@ export class ViewportHost {
private readonly sunLight = new DirectionalLight();
private readonly moonLight = new DirectionalLight();
private readonly godRaysLightSource = createScreenSpaceGodRaysLightSource();
private readonly lensFlareLightSource =
createScreenSpaceLensFlareLightSource();
private readonly distanceFogSkyColorSource =
createDistanceFogSkyColorSource();
private readonly localLightGroup = new Group();
@@ -1442,6 +1449,7 @@ export class ViewportHost {
}
constructor() {
initializeSharedKtx2Loader(this.renderer);
enableCameraRendererRenderCategories(this.perspectiveCamera);
enableCameraRendererRenderCategories(this.orthographicCamera);
enableCameraRendererRenderCategories(this.waterReflectionCamera);
@@ -2928,58 +2936,64 @@ export class ViewportHost {
this.distanceFogSkyColorSource,
resolveWorldBackgroundSkyColorState(displayedBackground, shaderSkyState)
);
const godRaysLightInput =
const screenSpaceSunLightInput =
shaderSkyState !== null
? resolveDominantScreenSpaceGodRaysLightInput(
shaderSkyState.celestial.sunVisible
? {
colorHex: shaderSkyState.celestial.sunColorHex,
intensity:
shaderSkyState.celestial.sunIntensity *
resolveWorldCelestialHorizonVisibility(
shaderSkyState.celestial.sunDirection.y,
shaderSkyState.sky.horizonHeight
),
direction: shaderSkyState.celestial.sunDirection
}
: null,
shaderSkyState.celestial.moonVisible
? {
colorHex: shaderSkyState.celestial.moonColorHex,
intensity:
shaderSkyState.celestial.moonIntensity *
resolveWorldCelestialHorizonVisibility(
shaderSkyState.celestial.moonDirection.y,
shaderSkyState.sky.horizonHeight
),
direction: shaderSkyState.celestial.moonDirection
}
: null
)
: resolveDominantScreenSpaceGodRaysLightInput(
celestialBodiesState.sun === null
? null
: {
colorHex: celestialBodiesState.sun.colorHex,
direction: celestialBodiesState.sun.direction,
intensity:
celestialBodiesState.sun.intensity *
celestialBodiesState.sun.horizonVisibility
},
celestialBodiesState.moon === null
? null
: {
colorHex: celestialBodiesState.moon.colorHex,
direction: celestialBodiesState.moon.direction,
intensity:
celestialBodiesState.moon.intensity *
celestialBodiesState.moon.horizonVisibility
}
);
? shaderSkyState.celestial.sunVisible
? {
colorHex: shaderSkyState.celestial.sunColorHex,
intensity:
shaderSkyState.celestial.sunIntensity *
resolveWorldCelestialHorizonVisibility(
shaderSkyState.celestial.sunDirection.y,
shaderSkyState.sky.horizonHeight
),
direction: shaderSkyState.celestial.sunDirection
}
: null
: celestialBodiesState.sun === null
? null
: {
colorHex: celestialBodiesState.sun.colorHex,
direction: celestialBodiesState.sun.direction,
intensity:
celestialBodiesState.sun.intensity *
celestialBodiesState.sun.horizonVisibility
};
const screenSpaceMoonLightInput =
shaderSkyState !== null
? shaderSkyState.celestial.moonVisible
? {
colorHex: shaderSkyState.celestial.moonColorHex,
intensity:
shaderSkyState.celestial.moonIntensity *
resolveWorldCelestialHorizonVisibility(
shaderSkyState.celestial.moonDirection.y,
shaderSkyState.sky.horizonHeight
),
direction: shaderSkyState.celestial.moonDirection
}
: null
: celestialBodiesState.moon === null
? null
: {
colorHex: celestialBodiesState.moon.colorHex,
direction: celestialBodiesState.moon.direction,
intensity:
celestialBodiesState.moon.intensity *
celestialBodiesState.moon.horizonVisibility
};
const godRaysLightInput = resolveDominantScreenSpaceGodRaysLightInput(
screenSpaceSunLightInput,
screenSpaceMoonLightInput
);
syncScreenSpaceGodRaysLightSource(
this.godRaysLightSource,
godRaysLightInput
);
syncScreenSpaceLensFlareLightSource(
this.lensFlareLightSource,
screenSpaceSunLightInput
);
const environmentState = resolveWorldEnvironmentState(
displayedBackground,
backgroundTexture,
@@ -3049,7 +3063,8 @@ export class ViewportHost {
settings,
this.worldBackgroundRenderer.scene,
this.godRaysLightSource,
this.distanceFogSkyColorSource
this.distanceFogSkyColorSource,
this.lensFlareLightSource
);
this.currentAdvancedRenderingSettings =
cloneAdvancedRenderingSettings(settings);
@@ -14173,6 +14188,7 @@ export class ViewportHost {
this.updateTransformGizmoPose();
this.updateTerrainLodVisibility();
this.foliageRenderer.updateView(this.getActiveCamera());
this.foliageRenderer.updateWind(dt);
this.volumeTime += dt;
for (const uniform of this.volumeAnimatedUniforms) {

View File

@@ -115,6 +115,10 @@ vi.mock("postprocessing", () => {
constructor() {}
}
class MockFXAAEffect {
constructor() {}
}
class MockSMAAEffect {
constructor() {}
}
@@ -125,6 +129,7 @@ vi.mock("postprocessing", () => {
DepthOfFieldEffect: MockDepthOfFieldEffect,
EffectComposer: MockEffectComposer,
EffectPass: MockEffectPass,
FXAAEffect: MockFXAAEffect,
NormalPass: MockNormalPass,
Pass: MockPass,
RenderPass: MockRenderPass,
@@ -167,6 +172,12 @@ import {
resolveGodRaysSourceMask,
syncScreenSpaceGodRaysLightSource
} from "../../src/rendering/screen-space-god-rays";
import {
createScreenSpaceLensFlareLightSource,
projectScreenSpaceLensFlareLight,
resolveLensFlareParameters,
syncScreenSpaceLensFlareLightSource
} from "../../src/rendering/screen-space-lens-flare";
import { resolveDynamicGlobalIlluminationParameters } from "../../src/rendering/screen-space-global-illumination";
import {
ALL_RENDER_LAYER_MASK,
@@ -183,6 +194,28 @@ import {
} from "../../src/rendering/whitebox-bevel-material";
import { resolveWorldBackgroundSkyColorState } from "../../src/rendering/world-background-renderer";
function resetPostprocessingState() {
postprocessingState.composerOptions.length = 0;
postprocessingState.composerPasses.length = 0;
postprocessingState.normalPassTextures.length = 0;
postprocessingState.ssaoCalls.length = 0;
}
function getLastEffectPassEffectNames() {
const effectPasses = postprocessingState.composerPasses.filter(
(pass) => (pass as { name?: string }).name === "EffectPass"
);
const lastEffectPass = effectPasses.at(-1) as
| { effects: unknown[] }
| undefined;
return (
lastEffectPass?.effects.map(
(effect) => (effect as { constructor: { name: string } }).constructor.name
) ?? []
);
}
describe("resolveBoxVolumeRenderPaths", () => {
it("uses authored fog and water paths when advanced rendering is enabled", () => {
const settings = createDefaultWorldSettings().advancedRendering;
@@ -453,9 +486,9 @@ describe("god rays parameters", () => {
}
};
expect(
resolveDominantScreenSpaceGodRaysLightInput(visibleSun, null)
).toBe(visibleSun);
expect(resolveDominantScreenSpaceGodRaysLightInput(visibleSun, null)).toBe(
visibleSun
);
expect(
resolveDominantScreenSpaceGodRaysLightInput(visibleSun, visibleMoon)
).toBe(visibleMoon);
@@ -499,9 +532,98 @@ describe("god rays parameters", () => {
).toBeGreaterThan(0.5);
expect(resolveGodRaysSourceMask(0.035)).toBeLessThan(0.5);
expect(resolveGodRaysSourceMask(0.035, 2)).toBeGreaterThan(0.5);
expect(resolveGodRaysSourceMask(GOD_RAYS_SOURCE_MASK_RADII.haloOuter)).toBe(
0
);
});
});
describe("lens flare parameters", () => {
it("keeps the pass disabled by default", () => {
const settings = createDefaultWorldSettings().advancedRendering;
expect(resolveLensFlareParameters(settings.lensFlare)).toMatchObject({
enabled: false
});
});
it("resolves bounded screen-space flare parameters", () => {
const settings = createDefaultWorldSettings().advancedRendering.lensFlare;
settings.enabled = true;
settings.intensity = 12;
settings.haloSize = 8;
settings.ghostIntensity = 9;
settings.ghostCount = 99;
expect(resolveLensFlareParameters(settings)).toEqual({
enabled: true,
intensity: 4,
haloSize: 3,
ghostIntensity: 3,
ghostCount: 8
});
});
it("syncs the visible sun light source", () => {
const lightSource = createScreenSpaceLensFlareLightSource();
syncScreenSpaceLensFlareLightSource(lightSource, {
colorHex: "#ffe0aa",
intensity: 1.4,
direction: {
x: 0.1,
y: 0.7,
z: -0.4
}
});
expect(lightSource).toEqual({
colorHex: "#ffe0aa",
intensity: 1.4,
direction: {
x: 0.1,
y: 0.7,
z: -0.4
}
});
syncScreenSpaceLensFlareLightSource(lightSource, null);
expect(lightSource).toEqual({
colorHex: "#ffffff",
intensity: 0,
direction: null
});
});
it("projects the lens flare light direction and rejects behind-camera lights", () => {
const camera = new PerspectiveCamera(60, 1, 0.1, 1000);
const projection = projectScreenSpaceLensFlareLight(camera, {
colorHex: "#ffffff",
intensity: 1,
direction: {
x: 0,
y: 0,
z: -1
}
});
expect(projection?.screenPosition.x).toBeCloseTo(0.5, 6);
expect(projection?.screenPosition.y).toBeCloseTo(0.5, 6);
expect(projection?.visibility).toBeCloseTo(1, 6);
expect(
resolveGodRaysSourceMask(GOD_RAYS_SOURCE_MASK_RADII.haloOuter)
).toBe(0);
projectScreenSpaceLensFlareLight(camera, {
colorHex: "#ffffff",
intensity: 1,
direction: {
x: 0,
y: 0,
z: 1
}
})
).toBeNull();
});
});
@@ -545,6 +667,131 @@ describe("createAdvancedRenderingComposer", () => {
expect(postprocessingState.ssaoCalls).toHaveLength(0);
});
it("keeps SMAA as the default post-process anti-aliasing effect", () => {
resetPostprocessingState();
const settings = createDefaultWorldSettings().advancedRendering;
settings.enabled = true;
createAdvancedRenderingComposer(
{
capabilities: {
isWebGL2: true
}
} as unknown as never,
new Scene(),
new PerspectiveCamera(),
settings
);
expect(postprocessingState.composerOptions[0]).toMatchObject({
multisampling: 0
});
expect(getLastEffectPassEffectNames()).toEqual([
"MockToneMappingEffect",
"MockSMAAEffect"
]);
});
it("can disable anti-aliasing in the post stack", () => {
resetPostprocessingState();
const settings = createDefaultWorldSettings().advancedRendering;
settings.enabled = true;
settings.antiAliasing.enabled = false;
createAdvancedRenderingComposer(
{
capabilities: {
isWebGL2: true
}
} as unknown as never,
new Scene(),
new PerspectiveCamera(),
settings
);
expect(postprocessingState.composerOptions[0]).toMatchObject({
multisampling: 0
});
expect(getLastEffectPassEffectNames()).toEqual(["MockToneMappingEffect"]);
});
it("uses FXAA when selected", () => {
resetPostprocessingState();
const settings = createDefaultWorldSettings().advancedRendering;
settings.enabled = true;
settings.antiAliasing.mode = "fxaa";
createAdvancedRenderingComposer(
{
capabilities: {
isWebGL2: true
}
} as unknown as never,
new Scene(),
new PerspectiveCamera(),
settings
);
expect(postprocessingState.composerOptions[0]).toMatchObject({
multisampling: 0
});
expect(getLastEffectPassEffectNames()).toEqual([
"MockToneMappingEffect",
"MockFXAAEffect"
]);
});
it("uses composer MSAA samples on WebGL2 when an MSAA mode is selected", () => {
resetPostprocessingState();
const settings = createDefaultWorldSettings().advancedRendering;
settings.enabled = true;
settings.antiAliasing.mode = "msaa4x";
createAdvancedRenderingComposer(
{
capabilities: {
isWebGL2: true
}
} as unknown as never,
new Scene(),
new PerspectiveCamera(),
settings
);
expect(postprocessingState.composerOptions[0]).toMatchObject({
multisampling: 4
});
expect(getLastEffectPassEffectNames()).toEqual(["MockToneMappingEffect"]);
});
it("falls back to no composer MSAA samples outside WebGL2", () => {
resetPostprocessingState();
const settings = createDefaultWorldSettings().advancedRendering;
settings.enabled = true;
settings.antiAliasing.mode = "msaa8x";
createAdvancedRenderingComposer(
{
capabilities: {
isWebGL2: false
}
} as unknown as never,
new Scene(),
new PerspectiveCamera(),
settings
);
expect(postprocessingState.composerOptions[0]).toMatchObject({
multisampling: 0
});
expect(getLastEffectPassEffectNames()).toEqual(["MockToneMappingEffect"]);
});
it("adds distance fog before post-world overlay layers", () => {
postprocessingState.composerOptions.length = 0;
postprocessingState.composerPasses.length = 0;
@@ -701,6 +948,102 @@ describe("createAdvancedRenderingComposer", () => {
).toBe(ALL_RENDER_LAYER_MASK);
});
it("adds lens flare before post-world overlay layers when a sun source is available", () => {
postprocessingState.composerOptions.length = 0;
postprocessingState.composerPasses.length = 0;
postprocessingState.normalPassTextures.length = 0;
postprocessingState.ssaoCalls.length = 0;
const settings = createDefaultWorldSettings().advancedRendering;
const lightSource = createScreenSpaceLensFlareLightSource();
settings.enabled = true;
settings.lensFlare.enabled = true;
syncScreenSpaceLensFlareLightSource(lightSource, {
colorHex: "#fff1cc",
intensity: 1,
direction: {
x: 0,
y: 0.25,
z: -1
}
});
createAdvancedRenderingComposer(
{
capabilities: {
isWebGL2: true
}
} as unknown as never,
new Scene(),
new PerspectiveCamera(),
settings,
null,
null,
null,
lightSource
);
expect(
postprocessingState.composerPasses.map(
(pass) => (pass as { name: string }).name
)
).toEqual([
"RenderPass",
"ScreenSpaceLensFlarePass",
"RenderPass",
"RenderPass",
"EffectPass"
]);
expect(
(postprocessingState.composerPasses[0] as { renderLayerMask?: number })
.renderLayerMask
).toBe(AO_WORLD_RENDER_LAYER_MASK);
expect(
(postprocessingState.composerPasses[1] as { needsDepthTexture?: boolean })
.needsDepthTexture
).toBe(true);
expect(
(postprocessingState.composerPasses[2] as { renderLayerMask?: number })
.renderLayerMask
).toBe(POST_AO_TRANSPARENT_RENDER_LAYER_MASK);
expect(
(postprocessingState.composerPasses[3] as { renderLayerMask?: number })
.renderLayerMask
).toBe(OVERLAY_RENDER_LAYER_MASK);
});
it("does not add lens flare when the feature is enabled without a light source", () => {
postprocessingState.composerOptions.length = 0;
postprocessingState.composerPasses.length = 0;
postprocessingState.normalPassTextures.length = 0;
postprocessingState.ssaoCalls.length = 0;
const settings = createDefaultWorldSettings().advancedRendering;
settings.enabled = true;
settings.lensFlare.enabled = true;
createAdvancedRenderingComposer(
{
capabilities: {
isWebGL2: true
}
} as unknown as never,
new Scene(),
new PerspectiveCamera(),
settings
);
expect(
postprocessingState.composerPasses.map(
(pass) => (pass as { name: string }).name
)
).toEqual(["RenderPass", "EffectPass"]);
expect(
(postprocessingState.composerPasses[0] as { renderLayerMask?: number })
.renderLayerMask
).toBe(ALL_RENDER_LAYER_MASK);
});
it("adds the dynamic GI pass only when dynamic GI is enabled", () => {
postprocessingState.composerOptions.length = 0;
postprocessingState.composerPasses.length = 0;

View File

@@ -1,6 +1,9 @@
import {
Group,
InstancedMesh,
MeshStandardMaterial,
PerspectiveCamera,
type Material,
type Object3D
} from "three";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -27,7 +30,7 @@ vi.mock("../../src/foliage/bundled-foliage-model-loader", async () => {
template.add(
new three.Mesh(
new three.BoxGeometry(1, 1, 1),
new three.MeshBasicMaterial()
new three.MeshStandardMaterial()
)
);
@@ -96,13 +99,17 @@ function createRendererInput() {
enabled: true,
densityMultiplier: 1,
maxDistanceMultiplier: 1,
shadows: "near" as const
shadows: "near" as const,
windEnabled: true,
windStrength: 1,
windSpeed: 1,
windDirectionDegrees: 35
}
};
}
function getInstancedMeshes(group: Group): Object3D[] {
const meshes: Object3D[] = [];
function getInstancedMeshes(group: Group): InstancedMesh[] {
const meshes: InstancedMesh[] = [];
group.traverse((object) => {
const maybeInstancedMesh = object as Object3D & {
@@ -110,13 +117,17 @@ function getInstancedMeshes(group: Group): Object3D[] {
};
if (maybeInstancedMesh.isInstancedMesh === true) {
meshes.push(object);
meshes.push(object as InstancedMesh);
}
});
return meshes;
}
function getSingleMaterial(material: Material | Material[]): Material {
return Array.isArray(material) ? material[0]! : material;
}
function getVisibleBatchKeys(group: Group): string[] {
const keys: string[] = [];
@@ -199,4 +210,136 @@ describe("FoliageInstancedRenderer", () => {
initialVisibleBatchKeys
);
});
it("patches standard foliage materials with GPU wind attributes and uniforms", async () => {
let rebuildCount = 0;
const rebuildWaiters: Array<() => void> = [];
const renderer = new FoliageInstancedRenderer({
onRebuilt: () => {
rebuildCount += 1;
rebuildWaiters.shift()?.();
}
});
const waitForRebuild = () =>
new Promise<void>((resolve) => {
rebuildWaiters.push(resolve);
});
const input = createRendererInput();
const initialRebuild = waitForRebuild();
renderer.sync(input);
renderer.updateView(createCamera({ x: 8, y: 8, z: 8 }));
await initialRebuild;
const instancedMesh = getInstancedMeshes(renderer.group)[0]!;
const material = getSingleMaterial(instancedMesh.material);
expect(material).toBeInstanceOf(MeshStandardMaterial);
expect(instancedMesh.geometry.getAttribute("foliageWind")?.itemSize).toBe(
2
);
expect(material.customProgramCacheKey()).toContain("foliage-wind-v1");
const shader = {
uniforms: {},
vertexShader:
"#include <common>\nvoid main() {\n#include <project_vertex>\n}",
fragmentShader: ""
} as any;
material.onBeforeCompile(shader, {} as never);
expect(shader.vertexShader).toContain("attribute vec2 foliageWind");
expect(shader.vertexShader).toContain("foliageWindStrength");
expect(shader.uniforms.foliageWindStrength.value).toBe(1);
expect(shader.uniforms.foliageWindSpeed.value).toBe(1);
const initialLoadCallCount = loaderState.loadCalls.length;
const initialRebuildCount = rebuildCount;
renderer.sync({
...input,
quality: {
...input.quality,
windStrength: 2.25,
windSpeed: 3,
windDirectionDegrees: 90
}
});
await Promise.resolve();
expect(getInstancedMeshes(renderer.group)[0]).toBe(instancedMesh);
expect(loaderState.loadCalls).toHaveLength(initialLoadCallCount);
expect(rebuildCount).toBe(initialRebuildCount);
expect(shader.uniforms.foliageWindStrength.value).toBe(2.25);
expect(shader.uniforms.foliageWindSpeed.value).toBe(3);
expect(shader.uniforms.foliageWindDirection.value.x).toBeCloseTo(0, 6);
expect(shader.uniforms.foliageWindDirection.value.y).toBeCloseTo(1, 6);
renderer.updateWind(0.5);
expect(shader.uniforms.foliageWindTime.value).toBeCloseTo(0.5);
});
it("leaves foliage materials and geometry unpatched when wind is disabled", async () => {
const rebuildWaiters: Array<() => void> = [];
const renderer = new FoliageInstancedRenderer({
onRebuilt: () => {
rebuildWaiters.shift()?.();
}
});
const waitForRebuild = () =>
new Promise<void>((resolve) => {
rebuildWaiters.push(resolve);
});
const input = createRendererInput();
const initialRebuild = waitForRebuild();
renderer.sync({
...input,
quality: {
...input.quality,
windEnabled: false
}
});
renderer.updateView(createCamera({ x: 8, y: 8, z: 8 }));
await initialRebuild;
const instancedMesh = getInstancedMeshes(renderer.group)[0]!;
const material = getSingleMaterial(instancedMesh.material);
expect(instancedMesh.geometry.getAttribute("foliageWind")).toBeUndefined();
expect(material.customProgramCacheKey()).not.toContain("foliage-wind-v1");
});
it("writes zero wind influence for prototypes with no authored wind strength", async () => {
const rebuildWaiters: Array<() => void> = [];
const renderer = new FoliageInstancedRenderer({
onRebuilt: () => {
rebuildWaiters.shift()?.();
}
});
const waitForRebuild = () =>
new Promise<void>((resolve) => {
rebuildWaiters.push(resolve);
});
const input = createRendererInput();
const prototype = BUNDLED_FOLIAGE_PROTOTYPES[0]!;
input.bundledFoliagePrototypes[prototype.id] = {
...prototype,
windStrength: 0
};
const initialRebuild = waitForRebuild();
renderer.sync(input);
renderer.updateView(createCamera({ x: 8, y: 8, z: 8 }));
await initialRebuild;
const instancedMesh = getInstancedMeshes(renderer.group)[0]!;
const windAttribute = instancedMesh.geometry.getAttribute("foliageWind")!;
const values = Array.from(windAttribute.array as Float32Array);
expect(values.length).toBeGreaterThan(0);
expect(values.filter((_value, index) => index % 2 === 1)).toEqual(
new Array(values.length / 2).fill(0)
);
});
});

View File

@@ -341,7 +341,11 @@ describe("foliage render batch helpers", () => {
enabled: false,
densityMultiplier: 1,
maxDistanceMultiplier: 1,
shadows: "near"
shadows: "near",
windEnabled: true,
windStrength: 1,
windSpeed: 1,
windDirectionDegrees: 35
}
}
);
@@ -373,7 +377,11 @@ describe("foliage render batch helpers", () => {
enabled: true,
densityMultiplier: 1,
maxDistanceMultiplier: 1,
shadows: "near"
shadows: "near",
windEnabled: true,
windStrength: 1,
windSpeed: 1,
windDirectionDegrees: 35
}
}
);

View File

@@ -3,14 +3,34 @@ import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { importModelAssetFromFile, importModelAssetFromFiles, loadModelAssetFromStorage } from "../../src/assets/gltf-model-import";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
import {
createConfiguredGltfLoader,
importModelAssetFromFile,
importModelAssetFromFiles,
loadModelAssetFromStorage
} from "../../src/assets/gltf-model-import";
import { createInMemoryProjectAssetStorage } from "../../src/assets/project-asset-storage";
const tinyGlbFixturePath = path.resolve(process.cwd(), "fixtures/assets/tiny-triangle.glb");
const externalTriangleGltfPath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/scene.gltf");
const externalTriangleBinPath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/triangle.bin");
const tinyGlbFixturePath = path.resolve(
process.cwd(),
"fixtures/assets/tiny-triangle.glb"
);
const externalTriangleGltfPath = path.resolve(
process.cwd(),
"fixtures/assets/external-triangle/scene.gltf"
);
const externalTriangleBinPath = path.resolve(
process.cwd(),
"fixtures/assets/external-triangle/triangle.bin"
);
function createTestFile(bytes: Uint8Array | Buffer, name: string, type: string): File {
function createTestFile(
bytes: Uint8Array | Buffer,
name: string,
type: string
): File {
const arrayBuffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(arrayBuffer).set(bytes);
@@ -25,10 +45,20 @@ function createTestFile(bytes: Uint8Array | Buffer, name: string, type: string):
}
describe("model import", () => {
it("configures meshopt decoding for GLTF/GLB assets", () => {
const loader = createConfiguredGltfLoader();
expect(loader.meshoptDecoder).toBe(MeshoptDecoder);
});
it("imports and reloads a tiny GLB fixture", async () => {
const storage = createInMemoryProjectAssetStorage();
const fileBytes = await readFile(tinyGlbFixturePath);
const file = createTestFile(fileBytes, "tiny-triangle.glb", "model/gltf-binary");
const file = createTestFile(
fileBytes,
"tiny-triangle.glb",
"model/gltf-binary"
);
const importedModel = await importModelAssetFromFile(file, storage);
@@ -39,9 +69,14 @@ describe("model import", () => {
const storedAsset = await storage.getAsset(importedModel.asset.storageKey);
expect(Object.keys(storedAsset?.files ?? {})).toEqual(["tiny-triangle.glb"]);
expect(Object.keys(storedAsset?.files ?? {})).toEqual([
"tiny-triangle.glb"
]);
const reloadedAsset = await loadModelAssetFromStorage(storage, importedModel.asset);
const reloadedAsset = await loadModelAssetFromStorage(
storage,
importedModel.asset
);
expect(reloadedAsset.metadata.format).toBe("glb");
expect(reloadedAsset.template.children.length).toBeGreaterThan(0);
@@ -62,13 +97,21 @@ describe("model import", () => {
expect(importedModel.asset.mimeType).toBe("model/gltf+json");
expect(importedModel.asset.metadata.format).toBe("gltf");
expect(importedModel.asset.byteLength).toBe(gltfBytes.byteLength + binBytes.byteLength);
expect(importedModel.asset.byteLength).toBe(
gltfBytes.byteLength + binBytes.byteLength
);
const storedAsset = await storage.getAsset(importedModel.asset.storageKey);
expect(Object.keys(storedAsset?.files ?? {}).sort()).toEqual(["scene.gltf", "triangle.bin"]);
expect(Object.keys(storedAsset?.files ?? {}).sort()).toEqual([
"scene.gltf",
"triangle.bin"
]);
const reloadedAsset = await loadModelAssetFromStorage(storage, importedModel.asset);
const reloadedAsset = await loadModelAssetFromStorage(
storage,
importedModel.asset
);
expect(reloadedAsset.metadata.meshCount).toBe(1);
expect(reloadedAsset.template.children.length).toBeGreaterThan(0);

View File

@@ -1847,6 +1847,11 @@ describe("validateSceneDocument", () => {
mode: "filmic",
exposure: 0
},
antiAliasing: {
...document.world.advancedRendering.antiAliasing,
enabled: "yes",
mode: "temporal"
},
fogPath: "high",
waterPath: "ultra",
depthOfField: {
@@ -1881,12 +1886,24 @@ describe("validateSceneDocument", () => {
sourceSize: 0,
samples: 0
},
lensFlare: {
...document.world.advancedRendering.lensFlare,
enabled: "yes",
intensity: -0.1,
haloSize: 0,
ghostIntensity: Number.NaN,
ghostCount: 99
},
foliage: {
...document.world.advancedRendering.foliage,
enabled: "yes",
densityMultiplier: 9,
maxDistanceMultiplier: 0,
shadows: "close"
shadows: "close",
windEnabled: "yes",
windStrength: -0.1,
windSpeed: Number.NaN,
windDirectionDegrees: 420
}
} as any;
@@ -1946,6 +1963,14 @@ describe("validateSceneDocument", () => {
code: "invalid-advanced-rendering-tone-mapping-exposure",
path: "world.advancedRendering.toneMapping.exposure"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-anti-aliasing-enabled",
path: "world.advancedRendering.antiAliasing.enabled"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-anti-aliasing-mode",
path: "world.advancedRendering.antiAliasing.mode"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-dof-focal-length",
path: "world.advancedRendering.depthOfField.focalLength"
@@ -2026,6 +2051,26 @@ describe("validateSceneDocument", () => {
code: "invalid-advanced-rendering-god-rays-samples",
path: "world.advancedRendering.godRays.samples"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-lens-flare-enabled",
path: "world.advancedRendering.lensFlare.enabled"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-lens-flare-intensity",
path: "world.advancedRendering.lensFlare.intensity"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-lens-flare-halo-size",
path: "world.advancedRendering.lensFlare.haloSize"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-lens-flare-ghost-intensity",
path: "world.advancedRendering.lensFlare.ghostIntensity"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-lens-flare-ghost-count-range",
path: "world.advancedRendering.lensFlare.ghostCount"
}),
expect.objectContaining({
code: "invalid-foliage-quality-enabled",
path: "world.advancedRendering.foliage.enabled"
@@ -2042,6 +2087,22 @@ describe("validateSceneDocument", () => {
code: "invalid-foliage-quality-shadows",
path: "world.advancedRendering.foliage.shadows"
}),
expect.objectContaining({
code: "invalid-foliage-quality-wind-enabled",
path: "world.advancedRendering.foliage.windEnabled"
}),
expect.objectContaining({
code: "invalid-foliage-quality-wind-strength",
path: "world.advancedRendering.foliage.windStrength"
}),
expect.objectContaining({
code: "invalid-foliage-quality-wind-speed",
path: "world.advancedRendering.foliage.windSpeed"
}),
expect.objectContaining({
code: "invalid-foliage-quality-wind-direction",
path: "world.advancedRendering.foliage.windDirectionDegrees"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-fog-path",
path: "world.advancedRendering.fogPath"

View File

@@ -33,6 +33,9 @@ describe("world settings helpers", () => {
expect(clone.advancedRendering.dynamicGlobalIllumination).not.toBe(
source.advancedRendering.dynamicGlobalIllumination
);
expect(clone.advancedRendering.antiAliasing).not.toBe(
source.advancedRendering.antiAliasing
);
expect(clone.advancedRendering.whiteboxBevel).not.toBe(
source.advancedRendering.whiteboxBevel
);
@@ -178,6 +181,15 @@ describe("world settings helpers", () => {
expect(areWorldSettingsEqual(left, right)).toBe(false);
});
it("treats anti-aliasing settings as part of authored world equality", () => {
const left = createDefaultWorldSettings();
const right = cloneWorldSettings(left);
right.advancedRendering.antiAliasing.mode = "fxaa";
expect(areWorldSettingsEqual(left, right)).toBe(false);
});
it("treats god rays settings as part of authored world equality", () => {
const left = createDefaultWorldSettings();
const right = cloneWorldSettings(left);
@@ -196,22 +208,50 @@ describe("world settings helpers", () => {
expect(areWorldSettingsEqual(left, right)).toBe(false);
});
it("treats foliage wind settings as part of authored world equality", () => {
const left = createDefaultWorldSettings();
const right = cloneWorldSettings(left);
right.advancedRendering.foliage.windDirectionDegrees += 15;
expect(areWorldSettingsEqual(left, right)).toBe(false);
});
it("bounds resolved foliage quality multipliers", () => {
expect(
resolveFoliageQualitySettings({
enabled: true,
densityMultiplier: 9,
maxDistanceMultiplier: 0.01,
shadows: "full"
shadows: "full",
windEnabled: true,
windStrength: 9,
windSpeed: -1,
windDirectionDegrees: -45
})
).toEqual({
enabled: true,
densityMultiplier: 8,
maxDistanceMultiplier: 0.1,
shadows: "full"
shadows: "full",
windEnabled: true,
windStrength: 4,
windSpeed: 0,
windDirectionDegrees: 315
});
});
it("defaults missing legacy foliage wind settings during resolution", () => {
expect(
resolveFoliageQualitySettings({
enabled: true,
densityMultiplier: 1,
maxDistanceMultiplier: 1,
shadows: "near"
} as any)
).toEqual(createDefaultWorldSettings().advancedRendering.foliage);
});
it("treats the scene project-time lighting toggle as part of authored world equality", () => {
const left = createDefaultWorldSettings();
const right = cloneWorldSettings(left);

View File

@@ -1,14 +1,155 @@
import { describe, expect, it } from "vitest";
import { createTerrain } from "../../src/document/terrains";
import {
createTerrain,
getTerrainHeightAtSample,
type Terrain
} from "../../src/document/terrains";
import {
buildTerrainDerivedMeshData,
buildTerrainLodChunkMeshData,
buildTerrainLodMeshData,
type TerrainLodLevelMeshData,
resolveTerrainLodLevelIndex,
resolveTerrainLodLevelIndexWithHysteresis
} from "../../src/geometry/terrain-mesh";
function createJaggedTerrainHeights(
sampleCountX: number,
sampleCountZ: number
) {
return Array.from({ length: sampleCountX * sampleCountZ }, (_, index) => {
const sampleX = index % sampleCountX;
const sampleZ = Math.floor(index / sampleCountX);
return (
((sampleX * 17 + sampleZ * 31) % 13) -
6 +
(sampleX % 2 === 0 ? 0.35 : -0.2) +
(sampleZ % 3 === 0 ? 0.5 : -0.15)
);
});
}
function collectTopEdgeKeys(options: {
terrain: Terrain;
level: TerrainLodLevelMeshData;
axis: "x" | "z";
sampleCoordinate: number;
}): string[] {
const keys = new Set<string>();
const coordinateOffset = options.axis === "x" ? 0 : 2;
const varyingOffset = options.axis === "x" ? 2 : 0;
const epsilon = 1e-5;
for (
let positionOffset = 0;
positionOffset < options.level.positions.length;
positionOffset += 3
) {
const sampleCoordinate =
options.level.positions[positionOffset + coordinateOffset]! /
options.terrain.cellSize;
if (Math.abs(sampleCoordinate - options.sampleCoordinate) > epsilon) {
continue;
}
const varyingSample =
options.level.positions[positionOffset + varyingOffset]! /
options.terrain.cellSize;
const roundedVaryingSample = Math.round(varyingSample);
if (Math.abs(varyingSample - roundedVaryingSample) > epsilon) {
continue;
}
const sampleX =
options.axis === "x" ? options.sampleCoordinate : roundedVaryingSample;
const sampleZ =
options.axis === "x" ? roundedVaryingSample : options.sampleCoordinate;
const expectedHeight = getTerrainHeightAtSample(
options.terrain,
sampleX,
sampleZ
);
const height = options.level.positions[positionOffset + 1]!;
if (Math.abs(height - expectedHeight) > epsilon) {
continue;
}
keys.add(`${roundedVaryingSample}:${height.toFixed(6)}`);
}
return [...keys].sort(
(left, right) => Number(left.split(":")[0]) - Number(right.split(":")[0])
);
}
function collectLoweredEdgeVertexCount(options: {
terrain: Terrain;
level: TerrainLodLevelMeshData;
axis: "x" | "z";
sampleCoordinate: number;
}): number {
const coordinateOffset = options.axis === "x" ? 0 : 2;
const varyingOffset = options.axis === "x" ? 2 : 0;
const epsilon = 1e-5;
let count = 0;
for (
let positionOffset = 0;
positionOffset < options.level.positions.length;
positionOffset += 3
) {
const sampleCoordinate =
options.level.positions[positionOffset + coordinateOffset]! /
options.terrain.cellSize;
if (Math.abs(sampleCoordinate - options.sampleCoordinate) > epsilon) {
continue;
}
const varyingSample =
options.level.positions[positionOffset + varyingOffset]! /
options.terrain.cellSize;
const roundedVaryingSample = Math.round(varyingSample);
const maxVaryingSample =
options.axis === "x"
? options.terrain.sampleCountZ - 1
: options.terrain.sampleCountX - 1;
if (Math.abs(varyingSample - roundedVaryingSample) > epsilon) {
continue;
}
if (
roundedVaryingSample === 0 ||
roundedVaryingSample === maxVaryingSample
) {
continue;
}
const sampleX =
options.axis === "x" ? options.sampleCoordinate : roundedVaryingSample;
const sampleZ =
options.axis === "x" ? roundedVaryingSample : options.sampleCoordinate;
const expectedHeight = getTerrainHeightAtSample(
options.terrain,
sampleX,
sampleZ
);
const height = options.level.positions[positionOffset + 1]!;
if (height < expectedHeight - epsilon) {
count += 1;
}
}
return count;
}
describe("terrain mesh generation", () => {
it("chooses the forward diagonal when the opposite-corner height delta is smaller", () => {
const terrain = createTerrain({
@@ -60,27 +201,16 @@ describe("terrain mesh generation", () => {
});
const derivedMesh = buildTerrainDerivedMeshData(terrain);
expect(Array.from(derivedMesh.uvs)).toEqual([10, -6, 12, -6, 10, -4, 12, -4]);
expect(Array.from(derivedMesh.uvs)).toEqual([
10, -6, 12, -6, 10, -4, 12, -4
]);
});
it("derives full per-vertex layer weights from the compact terrain paint data", () => {
const terrain = createTerrain({
sampleCountX: 2,
sampleCountZ: 2,
paintWeights: [
0.2,
0.3,
0.1,
0,
0.5,
0,
0.1,
0.1,
0.1,
0.25,
0.25,
0.25
]
paintWeights: [0.2, 0.3, 0.1, 0, 0.5, 0, 0.1, 0.1, 0.1, 0.25, 0.25, 0.25]
});
const derivedMesh = buildTerrainDerivedMeshData(terrain);
@@ -177,11 +307,7 @@ describe("terrain mesh generation", () => {
expect(chunk).toBeDefined();
expect(chunk!.levels.map((level) => level.stride)).toEqual([
1,
2,
4,
8,
16
1, 2, 4, 8, 16
]);
const vertexCounts = chunk!.levels.map(
@@ -221,11 +347,7 @@ describe("terrain mesh generation", () => {
const [chunk] = buildTerrainLodMeshData(terrain).chunks;
const [level] = chunk!.levels;
for (
let offset = 0;
offset < level!.layerWeights.length;
offset += 8
) {
for (let offset = 0; offset < level!.layerWeights.length; offset += 8) {
const sum =
level!.layerWeights[offset]! +
level!.layerWeights[offset + 1]! +
@@ -240,7 +362,7 @@ describe("terrain mesh generation", () => {
}
});
it("adds terrain LoD skirt vertices to hide hard-switch edge cracks", () => {
it("adds terrain LoD skirt vertices on terrain perimeter edges", () => {
const terrain = createTerrain({
sampleCountX: 9,
sampleCountZ: 9
@@ -253,6 +375,116 @@ describe("terrain mesh generation", () => {
expect(level!.positions.length / 3).toBeGreaterThan(9 * 9);
});
it("keeps shared terrain LoD chunk borders at full source resolution for every level", () => {
const terrain = createTerrain({
sampleCountX: 129,
sampleCountZ: 129,
heights: createJaggedTerrainHeights(129, 129)
});
const lodMesh = buildTerrainLodMeshData(terrain);
const westChunk = lodMesh.chunks.find(
(chunk) => chunk.chunkX === 0 && chunk.chunkZ === 0
);
const eastChunk = lodMesh.chunks.find(
(chunk) => chunk.chunkX === 1 && chunk.chunkZ === 0
);
const southChunk = lodMesh.chunks.find(
(chunk) => chunk.chunkX === 0 && chunk.chunkZ === 0
);
const northChunk = lodMesh.chunks.find(
(chunk) => chunk.chunkX === 0 && chunk.chunkZ === 1
);
expect(westChunk).toBeDefined();
expect(eastChunk).toBeDefined();
expect(southChunk).toBeDefined();
expect(northChunk).toBeDefined();
const expectedVerticalEdge = Array.from({ length: 65 }, (_, sampleZ) => {
const height = getTerrainHeightAtSample(terrain, 64, sampleZ);
return `${sampleZ}:${height.toFixed(6)}`;
});
const expectedHorizontalEdge = Array.from({ length: 65 }, (_, sampleX) => {
const height = getTerrainHeightAtSample(terrain, sampleX, 64);
return `${sampleX}:${height.toFixed(6)}`;
});
for (const level of westChunk!.levels) {
expect(
collectTopEdgeKeys({
terrain,
level,
axis: "x",
sampleCoordinate: 64
})
).toEqual(expectedVerticalEdge);
}
for (const level of eastChunk!.levels) {
expect(
collectTopEdgeKeys({
terrain,
level,
axis: "x",
sampleCoordinate: 64
})
).toEqual(expectedVerticalEdge);
}
for (const level of southChunk!.levels) {
expect(
collectTopEdgeKeys({
terrain,
level,
axis: "z",
sampleCoordinate: 64
})
).toEqual(expectedHorizontalEdge);
}
for (const level of northChunk!.levels) {
expect(
collectTopEdgeKeys({
terrain,
level,
axis: "z",
sampleCoordinate: 64
})
).toEqual(expectedHorizontalEdge);
}
});
it("does not add vertical terrain LoD skirts on shared chunk borders", () => {
const terrain = createTerrain({
sampleCountX: 129,
sampleCountZ: 65,
heights: createJaggedTerrainHeights(129, 65)
});
const lodMesh = buildTerrainLodMeshData(terrain);
const westChunk = lodMesh.chunks.find(
(chunk) => chunk.chunkX === 0 && chunk.chunkZ === 0
);
const eastChunk = lodMesh.chunks.find(
(chunk) => chunk.chunkX === 1 && chunk.chunkZ === 0
);
expect(westChunk).toBeDefined();
expect(eastChunk).toBeDefined();
for (const level of [...westChunk!.levels, ...eastChunk!.levels]) {
expect(
collectLoweredEdgeVertexCount({
terrain,
level,
axis: "x",
sampleCoordinate: 64
})
).toBe(0);
}
});
it("selects coarser terrain LoD levels as perspective distance increases", () => {
const levelCount = 5;
const chunkDiagonal = 100;

View File

@@ -22,6 +22,7 @@ import {
} from "../../src/document/terrains";
import {
ATMOSPHERE_POLISH_SCENE_DOCUMENT_VERSION,
ANTI_ALIASING_SCENE_DOCUMENT_VERSION,
AUTHORED_TERRAIN_PAINT_SCENE_DOCUMENT_VERSION,
AUTHORED_TERRAIN_FOUNDATION_SCENE_DOCUMENT_VERSION,
CAMERA_RIG_MAPPED_RAIL_SCENE_DOCUMENT_VERSION,
@@ -44,7 +45,9 @@ import {
FOLIAGE_FOUNDATION_SCENE_DOCUMENT_VERSION,
FOLIAGE_MASKS_SCENE_DOCUMENT_VERSION,
FOLIAGE_QUALITY_SCENE_DOCUMENT_VERSION,
FOLIAGE_WIND_SCENE_DOCUMENT_VERSION,
IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION,
LENS_FLARE_SCENE_DOCUMENT_VERSION,
LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION,
MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION,
NPC_ENTITY_FOUNDATION_SCENE_DOCUMENT_VERSION,
@@ -1170,9 +1173,7 @@ describe("scene document JSON", () => {
expect(
migratedDocument.splineCorridorJunctions[junction.id]?.shapeMode
).toBe("straight");
expect(CUSTOM_PBR_MATERIALS_SCENE_DOCUMENT_VERSION).toBe(
SCENE_DOCUMENT_VERSION
);
expect(FOLIAGE_WIND_SCENE_DOCUMENT_VERSION).toBe(112);
});
it("round-trips spline corridor junctions", () => {
@@ -1526,6 +1527,10 @@ describe("scene document JSON", () => {
mode: "acesFilmic",
exposure: 1.25
},
antiAliasing: {
enabled: true,
mode: "fxaa"
},
whiteboxBevel: {
enabled: true,
edgeWidth: 0.18,
@@ -1551,11 +1556,22 @@ describe("scene document JSON", () => {
sourceSize: 1.35,
samples: 40
},
lensFlare: {
enabled: true,
intensity: 0.9,
haloSize: 1.35,
ghostIntensity: 0.8,
ghostCount: 6
},
foliage: {
enabled: true,
densityMultiplier: 0.75,
maxDistanceMultiplier: 1.25,
shadows: "near"
shadows: "near",
windEnabled: true,
windStrength: 1.4,
windSpeed: 1.8,
windDirectionDegrees: 120
},
fogPath: "quality",
waterPath: "performance",
@@ -1779,6 +1795,46 @@ describe("scene document JSON", () => {
);
});
it("migrates v110 scene documents without lens flare settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy Lens Flare Scene"
});
const legacyAdvancedRendering: Partial<
typeof emptyScene.world.advancedRendering
> = {
...emptyScene.world.advancedRendering
};
delete legacyAdvancedRendering.lensFlare;
const migratedDocument = migrateSceneDocument({
version: CUSTOM_PBR_MATERIALS_SCENE_DOCUMENT_VERSION,
name: emptyScene.name,
time: emptyScene.time,
scheduler: emptyScene.scheduler,
world: {
...emptyScene.world,
advancedRendering: legacyAdvancedRendering
},
materials: emptyScene.materials,
textures: emptyScene.textures,
assets: emptyScene.assets,
brushes: emptyScene.brushes,
terrains: emptyScene.terrains,
foliagePrototypes: emptyScene.foliagePrototypes,
foliageLayers: emptyScene.foliageLayers,
paths: emptyScene.paths,
splineCorridorJunctions: emptyScene.splineCorridorJunctions,
modelInstances: emptyScene.modelInstances,
entities: emptyScene.entities,
interactionLinks: emptyScene.interactionLinks
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering.lensFlare).toEqual(
emptyScene.world.advancedRendering.lensFlare
);
});
it("migrates v93 scene documents without foliage quality settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy Foliage Quality Scene"
@@ -1814,6 +1870,59 @@ describe("scene document JSON", () => {
);
});
it("migrates v111 scene documents without foliage wind settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy Foliage Wind Scene"
});
const legacyDocument = JSON.parse(serializeSceneDocument(emptyScene)) as {
version: number;
world: {
advancedRendering: {
foliage: Record<string, unknown>;
};
};
};
const legacyFoliage = legacyDocument.world.advancedRendering
.foliage as Record<string, unknown>;
legacyDocument.version = LENS_FLARE_SCENE_DOCUMENT_VERSION;
delete legacyFoliage.windEnabled;
delete legacyFoliage.windStrength;
delete legacyFoliage.windSpeed;
delete legacyFoliage.windDirectionDegrees;
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering.foliage).toEqual(
emptyScene.world.advancedRendering.foliage
);
});
it("migrates v112 scene documents without anti-aliasing settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy Anti-Aliasing Scene"
});
const legacyDocument = JSON.parse(serializeSceneDocument(emptyScene)) as {
version: number;
world: {
advancedRendering: Record<string, unknown>;
};
};
const legacyAdvancedRendering = legacyDocument.world.advancedRendering;
legacyDocument.version = FOLIAGE_WIND_SCENE_DOCUMENT_VERSION;
delete legacyAdvancedRendering.antiAliasing;
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering.antiAliasing).toEqual(
emptyScene.world.advancedRendering.antiAliasing
);
expect(ANTI_ALIASING_SCENE_DOCUMENT_VERSION).toBe(SCENE_DOCUMENT_VERSION);
});
it("defaults missing water reflection mode and clamps legacy foam limits during migration", () => {
const migratedDocument = migrateSceneDocument({
version: SCENE_DOCUMENT_VERSION,