Compare commits
58 Commits
31a337da6a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| baf4215454 | |||
| 572e1434e4 | |||
| 69b13e9738 | |||
| bbc2edc3d1 | |||
| 02e8ca7432 | |||
| 93750e9b47 | |||
| dd526475e4 | |||
| 51a7de1068 | |||
| 27f06c8b10 | |||
| 8a5611ed4c | |||
| c010602b45 | |||
| 753f66e2e6 | |||
| 0272f186bc | |||
| 77f1a19310 | |||
| ee65a479e3 | |||
| a60a963a00 | |||
| 5a90ba88cc | |||
| 25df34d485 | |||
| 572d0ebf46 | |||
| 375029afb0 | |||
| cc7a8ca768 | |||
| 26e47fd275 | |||
| fe1bb3b9ce | |||
| d0a33aa97d | |||
| f3979f1ba4 | |||
| c7abf10930 | |||
| 18ff8464d6 | |||
| 1995257790 | |||
| 15884a6b92 | |||
| 7f8247691f | |||
| 8e36b04aa7 | |||
| 2b711725a0 | |||
| 4141907a70 | |||
| 33d0f2412e | |||
| 54b026eac0 | |||
| d09a826d19 | |||
| 4b32e3728d | |||
| 62626d047d | |||
| bf7a91326f | |||
| 280d6449a3 | |||
| 1f3cc4af6c | |||
| 18da770588 | |||
| 51eb35f199 | |||
| 2e58420d53 | |||
| 107ccbf640 | |||
| beabde908d | |||
| b371bc4ecd | |||
| ad83da2f52 | |||
| e9dcbe9b01 | |||
| fdf153dc47 | |||
| ed71146872 | |||
| 5a91424f83 | |||
| 9af35d8bea | |||
| b42603b4ce | |||
| 786dfa5ecf | |||
| 0a4e8dd8ee | |||
| e16bda0ba4 | |||
| fa5d1bd417 |
19
public/basis/basis_transcoder.js
Normal file
19
public/basis/basis_transcoder.js
Normal file
File diff suppressed because one or more lines are too long
BIN
public/basis/basis_transcoder.wasm
Normal file
BIN
public/basis/basis_transcoder.wasm
Normal file
Binary file not shown.
616
src/app/App.tsx
616
src/app/App.tsx
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
77
src/assets/ktx2-texture-support.ts
Normal file
77
src/assets/ktx2-texture-support.ts
Normal 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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)}.`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
448
src/rendering/screen-space-lens-flare.ts
Normal file
448
src/rendering/screen-space-lens-flare.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user