mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-18 20:01:01 +00:00
use renderer's max anisotropy, dispose of more resources
This commit is contained in:
parent
d31f3506a8
commit
409df9fcaa
68 changed files with 426 additions and 232 deletions
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>());
|
||||
|
|
|
|||
|
|
@ -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 ?? []);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
5
src/components/useAnisotropy.ts
Normal file
5
src/components/useAnisotropy.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { useThree } from "@react-three/fiber";
|
||||
|
||||
export function useAnisotropy(): number {
|
||||
return useThree((s) => s.gl.capabilities.getMaxAnisotropy());
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue