use renderer's max anisotropy, dispose of more resources

This commit is contained in:
Brian Beck 2026-03-12 20:57:59 -07:00
parent d31f3506a8
commit 409df9fcaa
68 changed files with 426 additions and 232 deletions

View file

@ -1,4 +1,4 @@
import { memo, useEffect, useRef } from "react";
import { memo, useEffect, useEffectEvent, useRef, useState } from "react";
import { useThree, useFrame } from "@react-three/fiber";
import {
Audio,
@ -253,6 +253,8 @@ export const AudioEmitter = memo(function AudioEmitter({
}
};
const [randomValue] = useState(() => Math.random());
// Create sound object on mount.
useEffect(() => {
if (!audioLoader || !audioListener) return;
@ -312,7 +314,7 @@ export const AudioEmitter = memo(function AudioEmitter({
const gapMin = Math.max(0, minLoopGap);
const gapMax = Math.max(gapMin, maxLoopGap);
const gap =
gapMin === gapMax ? gapMin : Math.random() * (gapMax - gapMin) + gapMin;
gapMin === gapMax ? gapMin : randomValue * (gapMax - gapMin) + gapMin;
sound.loop = false;
@ -340,7 +342,7 @@ export const AudioEmitter = memo(function AudioEmitter({
};
// Load and play audio. For 3D, gated by proximity; for 2D, plays immediately.
const loadAndPlay = (sound: Audio<GainNode | PannerNode>) => {
const loadAndPlay = useEffectEvent((sound: Audio<GainNode | PannerNode>) => {
if (!audioLoader) return;
const gen = generationRef.current;
if (!isLoadedRef.current) {
@ -373,7 +375,7 @@ export const AudioEmitter = memo(function AudioEmitter({
/* expected */
}
}
};
});
// 2D emitters: load and play on mount (no proximity gating).
useEffect(() => {

View file

@ -67,7 +67,6 @@
cursor: not-allowed;
}
/* Secondary/ghost variant for less prominent actions (Close, Cancel). */
.Secondary {
composes: DialogButton;
background: transparent;
@ -81,3 +80,23 @@
color: rgba(169, 255, 229, 0.8);
border: 1px solid rgba(63, 144, 135, 0.9);
}
.Actions {
display: flex;
align-items: center;
gap: 10px;
}
@media (max-width: 719px) {
.Dialog {
max-width: calc(100dvw - 20px);
max-height: calc(100dvh - 20px);
border-radius: 0;
}
}
@media (max-width: 639px) {
.Overlay {
padding: 10px;
}
}

View file

@ -20,6 +20,7 @@ import {
import type { AnimationAction } from "three";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import { setupTexture } from "../textureUtils";
import { useAnisotropy } from "./useAnisotropy";
import { useDebug, useSettings } from "./SettingsProvider";
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
import { useEngineSelector, effectNow, engineStore } from "../state/engineStore";
@ -37,6 +38,7 @@ import { injectShapeLighting } from "../shapeMaterial";
import {
processShapeScene,
replaceWithShapeMaterial,
disposeClonedScene,
} from "../stream/playbackUtils";
import type { ThreadState as StreamThreadState } from "../stream/types";
@ -198,7 +200,13 @@ const IflTexture = memo(function IflTexture({
animated = false,
}: TextureProps) {
const resourcePath = material.userData.resource_path;
const flagNames = new Set<string>(material.userData.flag_names ?? []);
const flagNames = useMemo(
() =>
material.userData.flag_names
? new Set<string>(material.userData.flag_names)
: EMPTY_FLAG_NAMES,
[material.userData.flag_names],
);
const iflPath = `textures/${resourcePath}.ifl`;
const texture = useIflTexture(iflPath);
@ -217,6 +225,8 @@ const IflTexture = memo(function IflTexture({
[material, texture, flagNames, isOrganic, vis, animated],
);
useDisposeMaterial(customMaterial);
// Two-pass rendering for organic/translucent materials
// Render BackSide first (with flipped normals), then FrontSide
if (Array.isArray(customMaterial)) {
@ -251,6 +261,18 @@ const IflTexture = memo(function IflTexture({
);
});
function useDisposeMaterial(material: MaterialResult) {
useEffect(() => {
return () => {
if (Array.isArray(material)) {
material.forEach((m) => m.dispose());
} else {
material.dispose();
}
};
}, [material]);
}
const EMPTY_FLAG_NAMES = new Set<string>();
const StaticTexture = memo(function StaticTexture({
@ -281,14 +303,15 @@ const StaticTexture = memo(function StaticTexture({
const isOrganic = shapeName && isOrganicShape(shapeName);
const isTranslucent = flagNames.has("Translucent");
const anisotropy = useAnisotropy();
const texture = useTexture(url, (texture) => {
// Organic/alpha-tested textures need special handling to avoid mipmap artifacts
if (isOrganic || isTranslucent) {
return setupTexture(texture, { disableMipmaps: true });
return setupTexture(texture, { disableMipmaps: true, anisotropy });
}
// Standard color texture setup for diffuse-only materials
return setupTexture(texture);
return setupTexture(texture, { anisotropy });
});
const customMaterial = useMemo(
@ -304,6 +327,8 @@ const StaticTexture = memo(function StaticTexture({
[material, texture, flagNames, isOrganic, vis, animated],
);
useDisposeMaterial(customMaterial);
// Two-pass rendering for organic/translucent materials
// Render BackSide first (with flipped normals), then FrontSide
if (Array.isArray(customMaterial)) {
@ -501,6 +526,7 @@ export const ShapeModel = memo(function ShapeModel({
const { debugMode } = useDebug();
const { animationEnabled } = useSettings();
const runtime = useEngineSelector((state) => state.runtime.runtime);
const anisotropy = useAnisotropy();
const { clonedScene, mixer, clipsByName, visNodesBySequence, iflMeshes } =
useMemo(() => {
@ -549,7 +575,7 @@ export const ShapeModel = memo(function ShapeModel({
}
});
processShapeScene(scene, shapeName ?? undefined);
processShapeScene(scene, shapeName ?? undefined, { anisotropy });
// Un-hide IFL meshes that don't have a vis sequence — they should always
// be visible. IFL meshes WITH vis sequences stay hidden until their
@ -607,7 +633,16 @@ export const ShapeModel = memo(function ShapeModel({
visNodesBySequence: visBySeq,
iflMeshes: iflInfos,
};
}, [gltf]);
}, [gltf, anisotropy]);
// Dispose cloned geometries and materials when the scene is replaced or
// the component unmounts, to prevent GPU memory from accumulating.
useEffect(() => {
return () => {
disposeClonedScene(clonedScene);
mixer?.uncacheRoot(clonedScene);
};
}, [clonedScene, mixer]);
const threadsRef = useRef(new Map<number, ThreadState>());
const iflMeshAtlasRef = useRef(new Map<any, IflAtlas>());

View file

@ -22,6 +22,7 @@ import {
import { setupTexture } from "../textureUtils";
import { FloatingLabel } from "./FloatingLabel";
import { useDebug } from "./SettingsProvider";
import { useAnisotropy } from "./useAnisotropy";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
import { injectInteriorLighting } from "../interiorMaterial";
@ -47,8 +48,9 @@ function InteriorTexture({
}) {
const debugContext = useDebug();
const debugMode = debugContext?.debugMode ?? false;
const anisotropy = useAnisotropy();
const url = textureToUrl(materialName);
const texture = useTexture(url, (texture) => setupTexture(texture));
const texture = useTexture(url, (texture) => setupTexture(texture, { anisotropy }));
// Check for self-illuminating flag in material userData
// Note: The io_dif Blender add-on needs to be updated to export material flags
const flagNames = new Set<string>(material?.userData?.flag_names ?? []);

View file

@ -386,18 +386,22 @@ export function MapInspector() {
) : null}
</footer>
{mapInfoOpen ? (
<Suspense>
<MapInfoDialog
onClose={() => setMapInfoOpen(false)}
missionName={missionName}
missionType={missionType ?? ""}
/>
</Suspense>
<ViewTransition>
<Suspense>
<MapInfoDialog
onClose={() => setMapInfoOpen(false)}
missionName={missionName}
missionType={missionType ?? ""}
/>
</Suspense>
</ViewTransition>
) : null}
{serverBrowserOpen ? (
<Suspense>
<ServerBrowser onClose={() => setServerBrowserOpen(false)} />
</Suspense>
<ViewTransition>
<Suspense>
<ServerBrowser onClose={() => setServerBrowserOpen(false)} />
</Suspense>
</ViewTransition>
) : null}
</SettingsProvider>
</RecordingProvider>

View file

@ -18,6 +18,7 @@ import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import {
ANIM_TRANSITION_TIME,
DEFAULT_EYE_HEIGHT,
disposeClonedScene,
getKeyframeAtTime,
getPosedNodeTransform,
processShapeScene,
@ -27,6 +28,7 @@ import { WeaponImageStateMachine } from "../stream/weaponStateMachine";
import type { WeaponAnimState } from "../stream/weaponStateMachine";
import { getAliasedActions } from "../torqueScript/shapeConstructor";
import { useStaticShape, ShapePlaceholder } from "./GenericShape";
import { useAnisotropy } from "./useAnisotropy";
import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
import { DebugSuspense } from "./DebugSuspense";
import { useAudio } from "./AudioContext";
@ -174,12 +176,13 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
const sn = shapeName?.toLowerCase();
return sn ? state.runtime.sequenceAliases.get(sn) : undefined;
});
const anisotropy = useAnisotropy();
// Clone scene preserving skeleton bindings, create mixer, find mount bones.
const { clonedScene, mixer, mount0, mount1, mount2, iflInitializers } =
useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group;
const iflInits = processShapeScene(scene);
const iflInits = processShapeScene(scene, undefined, { anisotropy });
// Use front-face-only rendering so the camera can see out from inside the
// model in first-person (backface culling hides interior faces).
@ -209,7 +212,14 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
mount2: m2,
iflInitializers: iflInits,
};
}, [gltf]);
}, [gltf, anisotropy]);
useEffect(() => {
return () => {
disposeClonedScene(clonedScene);
mixer.uncacheRoot(clonedScene);
};
}, [clonedScene, mixer]);
// Build case-insensitive clip lookup with alias support.
const animActionsRef = useRef(new Map<string, AnimationAction>());
@ -659,6 +669,7 @@ function WeaponModel({
}) {
const engineStore = useEngineStoreApi();
const weaponGltf = useStaticShape(weaponShape);
const anisotropy = useAnisotropy();
// Clone weapon with skeleton bindings, create dedicated mixer.
const {
@ -669,7 +680,7 @@ function WeaponModel({
weaponIflInitializers,
} = useMemo(() => {
const clone = SkeletonUtils.clone(weaponGltf.scene) as Group;
const iflInits = processShapeScene(clone);
const iflInits = processShapeScene(clone, undefined, { anisotropy });
// Compute Mountpoint inverse offset so the weapon's grip aligns to Mount0.
const mp = getPosedNodeTransform(
@ -714,7 +725,14 @@ function WeaponModel({
visNodesBySequence: visBySeq,
weaponIflInitializers: iflInits,
};
}, [weaponGltf]);
}, [weaponGltf, anisotropy]);
useEffect(() => {
return () => {
disposeClonedScene(weaponClone);
weaponMixer.uncacheRoot(weaponClone);
};
}, [weaponClone, weaponMixer]);
// Build case-insensitive action map for weapon animations.
const weaponActionsRef = useRef(new Map<string, AnimationAction>());
@ -1013,10 +1031,11 @@ function PackModel({
mountBone: Object3D;
}) {
const packGltf = useStaticShape(packShape);
const anisotropy = useAnisotropy();
const { packClone, packIflInitializers } = useMemo(() => {
const clone = SkeletonUtils.clone(packGltf.scene) as Group;
const iflInits = processShapeScene(clone);
const iflInits = processShapeScene(clone, undefined, { anisotropy });
// Compute Mountpoint inverse offset so the pack aligns to Mount1.
const mp = getPosedNodeTransform(
@ -1032,12 +1051,13 @@ function PackModel({
}
return { packClone: clone, packIflInitializers: iflInits };
}, [packGltf]);
}, [packGltf, anisotropy]);
useEffect(() => {
mountBone.add(packClone);
return () => {
mountBone.remove(packClone);
disposeClonedScene(packClone);
};
}, [packClone, mountBone]);

View file

@ -56,6 +56,7 @@
.Table {
width: 100%;
min-height: 0;
border-collapse: collapse;
font-size: 13px;
}
@ -133,6 +134,7 @@
.Footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 12px;
border-top: 1px solid rgba(0, 190, 220, 0.25);
@ -187,15 +189,11 @@
margin: 0 auto;
}
@media (max-width: 719px) {
.Dialog {
width: 100%;
height: 100%;
max-width: 100dvw;
max-height: 100dvh;
border-radius: 0;
}
.Actions {
composes: Actions from "./GameDialog.module.css";
}
@media (max-width: 719px) {
.Hint {
display: none;
}
@ -208,3 +206,29 @@
margin-left: auto;
}
}
@media (max-width: 539px) {
.Footer {
flex-direction: column;
gap: 8px;
}
.Actions {
width: 100%;
}
.WarriorLabel {
font-size: 14px;
}
.WarriorInput {
font-size: 16px;
min-width: 12em;
}
.JoinButton,
.CloseButton {
flex: 1 0 auto;
height: 32px;
}
}

View file

@ -195,16 +195,18 @@ export function ServerBrowser({ onClose }: { onClose: () => void }) {
/>
</div>
<span className={styles.Hint}>Double-click a server to join</span>
<button onClick={onClose} className={styles.CloseButton}>
Cancel
</button>
<button
onClick={handleJoinSelected}
disabled={!selectedAddress}
className={styles.JoinButton}
>
Join
</button>
<div className={styles.Actions}>
<button onClick={onClose} className={styles.CloseButton}>
Cancel
</button>
<button
onClick={handleJoinSelected}
disabled={!selectedAddress}
className={styles.JoinButton}
>
Join
</button>
</div>
</div>
</div>
</div>

View file

@ -7,6 +7,7 @@ import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import {
_r90,
_r90inv,
disposeClonedScene,
getPosedNodeTransform,
processShapeScene,
} from "../stream/playbackUtils";
@ -17,6 +18,7 @@ import {
} from "./useIflTexture";
import type { IflAtlas } from "./useIflTexture";
import { ShapeRenderer, useStaticShape } from "./GenericShape";
import { useAnisotropy } from "./useAnisotropy";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import type { TorqueObject } from "../torqueScript";
import type { ExplosionEntity, ShapeEntity } from "../state/gameEntityTypes";
@ -50,7 +52,6 @@ export function WeaponModel({ entity }: { entity: ShapeEntity }) {
const playerGltf = useStaticShape(playerShapeName);
const weaponGltf = useStaticShape(shapeName);
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const mountTransform = useMemo(() => {
// Get Mount0 from the player's posed skeleton with arm animation applied.
const armThread = getArmThread(shapeName);
@ -96,7 +97,13 @@ export function WeaponModel({ entity }: { entity: ShapeEntity }) {
const mountQuat = _r90.clone().multiply(combinedQuat).multiply(_r90inv);
return { position: mountPos, quaternion: mountQuat };
}, [playerGltf, weaponGltf]);
}, [
playerGltf.animations,
playerGltf.scene,
shapeName,
weaponGltf.animations,
weaponGltf.scene,
]);
const torqueObject = useMemo<TorqueObject>(
() => ({
@ -207,6 +214,7 @@ function interpolateSize(
export function ExplosionShape({ entity }: { entity: ExplosionEntity }) {
const playback = streamPlaybackStore.getState().playback;
const gltf = useStaticShape(entity.shapeName!);
const anisotropy = useAnisotropy();
const groupRef = useRef<Group>(null);
const startTimeRef = useRef(effectNow());
// eslint-disable-next-line react-hooks/purity
@ -267,7 +275,7 @@ export function ExplosionShape({ entity }: { entity: ExplosionEntity }) {
}
});
processShapeScene(scene, entity.shapeName);
processShapeScene(scene, entity.shapeName, { anisotropy });
// Collect vis-animated nodes keyed by sequence name.
const visNodes: VisNode[] = [];
@ -342,7 +350,14 @@ export function ExplosionShape({ entity }: { entity: ExplosionEntity }) {
});
return { scene, mixer, visNodes, iflInfos, materials };
}, [gltf, expBlock]);
}, [gltf, expBlock, anisotropy]);
useEffect(() => {
return () => {
disposeClonedScene(scene);
mixer?.uncacheRoot(scene);
};
}, [scene, mixer]);
// Load IFL texture atlases.
useEffect(() => {

View file

@ -1,13 +1,11 @@
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { Quaternion, Vector3 } from "three";
import {
DEFAULT_EYE_HEIGHT,
STREAM_TICK_SEC,
torqueHorizontalFovToThreeVerticalFov,
} from "../stream/playbackUtils";
import { shapeToUrl } from "../loaders";
import { ParticleEffects } from "./ParticleEffects";
import { PlayerEyeOffset } from "./PlayerModel";
import { stopAllTrackedSounds } from "./AudioEmitter";
@ -302,12 +300,6 @@ export function StreamingController({
savedConnectedPlayerName ?? recording.recorderName ?? undefined,
recordingDate: recording.recordingDate ?? undefined,
});
// Preload weapon effect shapes (explosions) so they're cached before
// the first projectile detonates -- otherwise the GLB fetch latency
// causes the short-lived explosion entity to expire before it renders.
for (const shape of stream.getEffectShapes()) {
useGLTF.preload(shapeToUrl(shape));
}
const snapshot = stream.getSnapshot();
streamPlaybackStore.setState({ time: snapshot.timeSec });

View file

@ -14,6 +14,7 @@ import {
import { setupTexture } from "../textureUtils";
import { updateTerrainTextureShader } from "../terrainMaterial";
import { useDebug } from "./SettingsProvider";
import { useAnisotropy } from "./useAnisotropy";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
@ -58,11 +59,12 @@ const BlendedTerrainTextures = memo(function BlendedTerrainTextures({
lightmap?: DataTexture;
}) {
const { debugMode } = useDebug();
const anisotropy = useAnisotropy();
const baseTextures = useTexture(
textureNames.map((name) => terrainTextureToUrl(name)),
(textures) => {
textures.forEach((tex) => setupTexture(tex));
textures.forEach((tex) => setupTexture(tex, { anisotropy }));
},
);
@ -74,7 +76,7 @@ const BlendedTerrainTextures = memo(function BlendedTerrainTextures({
const detailTexture = useTexture(
detailTextureUrl ?? FALLBACK_TEXTURE_URL,
(tex) => {
setupTexture(tex);
setupTexture(tex, { anisotropy });
},
);

View file

@ -9,6 +9,7 @@ import {
matrixFToQuaternion,
} from "../scene";
import { setupTexture } from "../textureUtils";
import { useAnisotropy } from "./useAnisotropy";
import { createWaterMaterial } from "../waterMaterial";
import { useDebug, useSettings } from "./SettingsProvider";
import { usePositionTracker } from "./usePositionTracker";
@ -61,7 +62,8 @@ export function WaterMaterial({
attach?: string;
}) {
const url = textureToUrl(surfaceTexture);
const texture = useTexture(url, (texture) => setupTexture(texture));
const anisotropy = useAnisotropy();
const texture = useTexture(url, (texture) => setupTexture(texture, { anisotropy }));
return (
<meshStandardMaterial
@ -303,13 +305,14 @@ const WaterReps = memo(function WaterReps({
}) {
const baseUrl = textureToUrl(surfaceTexture);
const envUrl = textureToUrl(envMapTexture ?? "special/lush_env");
const anisotropy = useAnisotropy();
const [baseTexture, envTexture] = useTexture(
[baseUrl, envUrl],
(textures) => {
const texArray = Array.isArray(textures) ? textures : [textures];
texArray.forEach((tex) => {
setupTexture(tex);
setupTexture(tex, { anisotropy });
tex.colorSpace = NoColorSpace;
tex.wrapS = RepeatWrapping;
tex.wrapT = RepeatWrapping;

View file

@ -0,0 +1,5 @@
import { useThree } from "@react-three/fiber";
export function useAnisotropy(): number {
return useThree((s) => s.gl.capabilities.getMaxAnisotropy());
}

View file

@ -64,11 +64,14 @@ const alphaAsRoughnessShaderModifier = (shader: any) => {
* Configures a texture for use with alpha-as-roughness materials
* @param texture - The texture to configure
*/
export function setupAlphaAsRoughnessTexture(texture: Texture) {
export function setupAlphaAsRoughnessTexture(
texture: Texture,
options: { anisotropy?: number } = {},
) {
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.colorSpace = SRGBColorSpace;
texture.flipY = false;
texture.anisotropy = 16;
texture.anisotropy = options.anisotropy ?? 1;
texture.generateMipmaps = true;
texture.minFilter = LinearMipmapLinearFilter;
texture.magFilter = LinearFilter;

View file

@ -27,7 +27,7 @@ import {
getFrameIndexForTime,
updateAtlasFrame,
} from "../components/useIflTexture";
import { getHullBoneIndices, filterGeometryByVertexGroups } from "../meshUtils";
import { getHullBoneIndices } from "../meshUtils";
import { loadTexture, setupTexture } from "../textureUtils";
import { textureToUrl } from "../loaders";
import type { Keyframe } from "./types";
@ -193,6 +193,51 @@ export function getPosedNodeTransform(
return { position, quaternion };
}
/**
* Remove faces influenced by hull bones, mutating the geometry in place.
* Unlike filterGeometryByVertexGroups (which clones), this is safe when the
* geometry is already our own copy (e.g. from SkeletonUtils.clone).
*/
function filterHullFaces(
geometry: BufferGeometry,
hullBoneIndices: Set<number>,
): void {
if (hullBoneIndices.size === 0 || !geometry.attributes.skinIndex) return;
const skinIndex = geometry.attributes.skinIndex;
const skinWeight = geometry.attributes.skinWeight;
const index = geometry.index;
if (!index) return;
const vertexHasHullInfluence = new Array(skinIndex.count).fill(false);
for (let i = 0; i < skinIndex.count; i++) {
for (let j = 0; j < 4; j++) {
const boneIndex = skinIndex.array[i * 4 + j];
const weight = skinWeight.array[i * 4 + j];
if (weight > 0.01 && hullBoneIndices.has(boneIndex)) {
vertexHasHullInfluence[i] = true;
break;
}
}
}
const newIndices: number[] = [];
const indexArray = index.array;
for (let i = 0; i < indexArray.length; i += 3) {
const i0 = indexArray[i];
const i1 = indexArray[i + 1];
const i2 = indexArray[i + 2];
if (
!vertexHasHullInfluence[i0] &&
!vertexHasHullInfluence[i1] &&
!vertexHasHullInfluence[i2]
) {
newIndices.push(i0, i1, i2);
}
}
geometry.setIndex(newIndices);
}
/**
* Smooth vertex normals across co-located split vertices (same position, different
* UVs). Matches the technique used by ShapeModel for consistent lighting.
@ -262,6 +307,7 @@ export function replaceWithShapeMaterial(
mat: MeshStandardMaterial,
vis: number,
isOrganic = false,
options: { anisotropy?: number } = {},
): ShapeMaterialResult {
const resourcePath: string | undefined = mat.userData?.resource_path;
const flagNames = new Set<string>(mat.userData?.flag_names ?? []);
@ -311,9 +357,9 @@ export function replaceWithShapeMaterial(
const texture = loadTexture(url);
const isTranslucent = flagNames.has("Translucent");
if (isOrganic || isTranslucent) {
setupTexture(texture, { disableMipmaps: true });
setupTexture(texture, { disableMipmaps: true, anisotropy: options.anisotropy });
} else {
setupTexture(texture);
setupTexture(texture, { anisotropy: options.anisotropy });
}
const result = createMaterialFromFlags(
@ -368,6 +414,7 @@ async function initializeIflMaterial(
export function processShapeScene(
scene: Object3D,
shapeName?: string,
options: { anisotropy?: number } = {},
): IflInitializer[] {
const iflInitializers: IflInitializer[] = [];
const isOrganic = shapeName ? isOrganicShape(shapeName) : false;
@ -402,14 +449,11 @@ export function processShapeScene(
}
// Filter hull-influenced triangles and smooth normals.
// SkeletonUtils.clone already deep-clones geometry, so no extra clone
// is needed — we can mutate in place.
if (node.geometry) {
let geometry = filterGeometryByVertexGroups(
node.geometry,
hullBoneIndices,
);
geometry = geometry.clone();
smoothVertexNormals(geometry);
node.geometry = geometry;
filterHullFaces(node.geometry, hullBoneIndices);
smoothVertexNormals(node.geometry);
}
// Replace PBR materials with diffuse-only Lambert materials.
@ -418,7 +462,7 @@ export function processShapeScene(
const vis: number = hasVisSequence ? 1 : (node.userData?.vis ?? 1);
if (Array.isArray(node.material)) {
node.material = node.material.map((m: MeshStandardMaterial) => {
const result = replaceWithShapeMaterial(m, vis, isOrganic);
const result = replaceWithShapeMaterial(m, vis, isOrganic, options);
if (result.initialize) {
iflInitializers.push({ mesh: node, initialize: result.initialize });
}
@ -430,7 +474,7 @@ export function processShapeScene(
return result.material;
});
} else if (node.material) {
const result = replaceWithShapeMaterial(node.material, vis, isOrganic);
const result = replaceWithShapeMaterial(node.material, vis, isOrganic, options);
if (result.initialize) {
iflInitializers.push({ mesh: node, initialize: result.initialize });
}
@ -451,6 +495,26 @@ export function processShapeScene(
return iflInitializers;
}
/**
* Dispose all geometries and materials on a cloned scene graph.
* Textures are intentionally left alone since they're shared via caches.
*/
export function disposeClonedScene(root: Object3D): void {
root.traverse((node: any) => {
if (node.geometry) {
node.geometry.dispose();
}
if (node.material) {
const mats: Material[] = Array.isArray(node.material)
? node.material
: [node.material];
for (const mat of mats) {
mat.dispose();
}
}
});
}
export function entityTypeColor(type: string): string {
switch (type.toLowerCase()) {
case "player":

View file

@ -84,6 +84,8 @@ export interface TextureSetupOptions {
repeat?: [number, number];
/** Disable mipmaps (for alpha-tested textures to prevent artifacts). Default: false */
disableMipmaps?: boolean;
/** Override anisotropy level. Default: max supported by the GPU. */
anisotropy?: number;
}
/**
@ -97,13 +99,13 @@ export function setupTexture<T extends Texture>(
tex: T,
options: TextureSetupOptions = {},
): T {
const { repeat = [1, 1], disableMipmaps = false } = options;
const { repeat = [1, 1], disableMipmaps = false, anisotropy } = options;
tex.wrapS = tex.wrapT = RepeatWrapping;
tex.colorSpace = SRGBColorSpace;
tex.repeat.set(...repeat);
tex.flipY = false; // DDS/DIF textures are already flipped
tex.anisotropy = 16;
tex.anisotropy = anisotropy ?? 1;
if (disableMipmaps) {
// Disable mipmaps - prevents checkerboard artifacts on alpha-tested materials