Release v0.1.9 2026-07-13
This commit is contained in:
+97
-17
@@ -1,15 +1,22 @@
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { Component, Suspense, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import * as THREE from "three";
|
||||
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
|
||||
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
|
||||
|
||||
const ROOM_CENTER_Z = ARENA_CENTER[1];
|
||||
const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href;
|
||||
const KAYKIT_DUNGEON_WALL_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/wall-pillar.glb", import.meta.url).href;
|
||||
const KAYKIT_DUNGEON_TORCH_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/torch-lit.glb", import.meta.url).href;
|
||||
const KAYKIT_DUNGEON_KIT_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/dungeon-kit-uastc.glb", import.meta.url).href;
|
||||
const DUNGEON_MESH_NAMES = {
|
||||
pillar: "pillar_decorated",
|
||||
wall: "wall_pillar",
|
||||
torch: "torch_lit",
|
||||
} as const;
|
||||
|
||||
type ArenaFixture = {
|
||||
position: readonly [number, number, number];
|
||||
@@ -106,21 +113,19 @@ function firstMesh(scene: THREE.Object3D) {
|
||||
return mesh as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||
}
|
||||
|
||||
function DungeonAssetInstances({
|
||||
url,
|
||||
function DungeonMeshInstances({
|
||||
mesh,
|
||||
fixtures,
|
||||
tint,
|
||||
opacity = 1,
|
||||
colors,
|
||||
}: {
|
||||
url: string;
|
||||
mesh: THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||
fixtures: readonly ArenaFixture[];
|
||||
tint: string;
|
||||
opacity?: number;
|
||||
colors?: readonly THREE.Color[];
|
||||
}) {
|
||||
const gltf = useGLTF(url, false, true);
|
||||
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
|
||||
const sourceMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
||||
const material = useMemo(() => {
|
||||
const next = sourceMaterial.clone();
|
||||
@@ -156,20 +161,77 @@ function DungeonAssetInstances({
|
||||
return <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
|
||||
}
|
||||
|
||||
function ArenaArchitecture({ room }: { room: BossRoomDefinition }) {
|
||||
const walls = useMemo(() => ARENA_WALL_SEGMENTS.map((fixture) => ({
|
||||
function LegacyDungeonAssetInstances({
|
||||
url,
|
||||
...props
|
||||
}: Omit<Parameters<typeof DungeonMeshInstances>[0], "mesh"> & { url: string }) {
|
||||
const gltf = useGLTF(url, false, true);
|
||||
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
|
||||
return <DungeonMeshInstances mesh={mesh} {...props} />;
|
||||
}
|
||||
|
||||
function arenaWalls(room: BossRoomDefinition) {
|
||||
return ARENA_WALL_SEGMENTS.map((fixture) => ({
|
||||
...fixture,
|
||||
scaleY: room.wallHeight / 4,
|
||||
})), [room.wallHeight]);
|
||||
}));
|
||||
}
|
||||
|
||||
function LegacyArenaArchitecture({ room }: { room: BossRoomDefinition }) {
|
||||
const walls = useMemo(() => arenaWalls(room), [room]);
|
||||
return (
|
||||
<group>
|
||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
||||
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
||||
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
||||
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function namedMesh(scene: THREE.Object3D, name: string) {
|
||||
const object = scene.getObjectByName(name);
|
||||
if (object instanceof THREE.Mesh) {
|
||||
return object as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||
}
|
||||
throw new Error(`Dungeon kit is missing mesh ${name}.`);
|
||||
}
|
||||
|
||||
function DungeonKitArchitecture({ room }: { room: BossRoomDefinition }) {
|
||||
const gltf = useGameGLTF(KAYKIT_DUNGEON_KIT_URL);
|
||||
const walls = useMemo(() => arenaWalls(room), [room]);
|
||||
const meshes = useMemo(() => ({
|
||||
pillar: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.pillar),
|
||||
wall: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.wall),
|
||||
torch: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.torch),
|
||||
}), [gltf.scene]);
|
||||
return (
|
||||
<group>
|
||||
<DungeonMeshInstances mesh={meshes.wall} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
||||
<DungeonMeshInstances mesh={meshes.pillar} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
||||
<DungeonMeshInstances mesh={meshes.torch} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
class DungeonAssetErrorBoundary extends Component<{
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
}, { failed: boolean }> {
|
||||
state = { failed: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown) {
|
||||
console.warn("Optimized dungeon asset failed; using legacy GLBs.", error);
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
||||
const walls = useRef<THREE.Group>(null);
|
||||
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
|
||||
@@ -205,14 +267,30 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
function LegacyRoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
return (
|
||||
<Suspense fallback={<RoomWallFallback room={room} />}>
|
||||
<ArenaArchitecture room={room} />
|
||||
<LegacyArenaArchitecture room={room} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function OptimizedRoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
return (
|
||||
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} />}>
|
||||
<Suspense fallback={<RoomWallFallback room={room} />}>
|
||||
<DungeonKitArchitecture room={room} />
|
||||
</Suspense>
|
||||
</DungeonAssetErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
return LEGACY_GAME_ASSETS_FORCED
|
||||
? <LegacyRoomWalls room={room} />
|
||||
: <OptimizedRoomWalls room={room} />;
|
||||
}
|
||||
|
||||
function RoomMarks({ room }: { room: BossRoomDefinition }) {
|
||||
const rays = useRef<THREE.InstancedMesh>(null);
|
||||
const pattern = ROOM_PATTERNS[room.floor];
|
||||
@@ -345,6 +423,8 @@ export function BossRoom() {
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
||||
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
||||
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
|
||||
if (LEGACY_GAME_ASSETS_FORCED) {
|
||||
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
||||
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
||||
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { Suspense, useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||
import { ALTERNATE_BOSS_CONFIG, bossVisualUrl } from "../game/bossVisuals";
|
||||
import type { BossId } from "../game/types";
|
||||
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
|
||||
|
||||
function PortraitModel({ bossId }: { bossId: BossId }) {
|
||||
const gltf = useGLTF(bossVisualUrl(bossId), false, true);
|
||||
const gltf = useGameGLTF(bossVisualUrl(bossId, !LEGACY_GAME_ASSETS_FORCED));
|
||||
const model = useMemo(() => {
|
||||
const clone = cloneSkeleton(gltf.scene);
|
||||
clone.updateMatrixWorld(true);
|
||||
@@ -49,7 +49,9 @@ export function BossTrophyPortrait({ bossId }: { bossId: BossId }) {
|
||||
<ambientLight intensity={1.9} />
|
||||
<directionalLight color="#fff3cf" intensity={3.2} position={[3, 5, 4]} />
|
||||
<directionalLight color={boss.accent} intensity={2.1} position={[-4, 2, -2]} />
|
||||
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
|
||||
<GameAssetProvider>
|
||||
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
|
||||
</GameAssetProvider>
|
||||
</Canvas>
|
||||
<span aria-hidden="true">{boss.icon}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { KTX2Loader } from "three-stdlib";
|
||||
|
||||
const BASIS_TRANSCODER_PATH = `${import.meta.env.BASE_URL}basis/`;
|
||||
const query = typeof window === "undefined" ? null : new URLSearchParams(window.location.search);
|
||||
|
||||
export const LEGACY_GAME_ASSETS_FORCED = import.meta.env.VITE_LEGACY_GAME_ASSETS === "1"
|
||||
|| import.meta.env.VITE_LEGACY_DUNGEON_ASSETS === "1"
|
||||
|| query?.has("legacyGameAssets") === true
|
||||
|| query?.has("legacyDungeonAssets") === true;
|
||||
|
||||
const Ktx2LoaderContext = createContext<KTX2Loader | null>(null);
|
||||
|
||||
export function selectedGameAssetUrl(legacyUrl: string, optimizedUrl: string) {
|
||||
return LEGACY_GAME_ASSETS_FORCED ? legacyUrl : optimizedUrl;
|
||||
}
|
||||
|
||||
export function GameAssetProvider({ children }: { children: ReactNode }) {
|
||||
const gl = useThree((state) => state.gl);
|
||||
const [ktx2Loader] = useState(() => LEGACY_GAME_ASSETS_FORCED
|
||||
? null
|
||||
: new KTX2Loader().setTranscoderPath(BASIS_TRANSCODER_PATH).detectSupport(gl));
|
||||
|
||||
useEffect(() => () => {
|
||||
ktx2Loader?.dispose();
|
||||
}, [ktx2Loader]);
|
||||
|
||||
return <Ktx2LoaderContext.Provider value={ktx2Loader}>{children}</Ktx2LoaderContext.Provider>;
|
||||
}
|
||||
|
||||
export function useGameGLTF(url: string) {
|
||||
const ktx2Loader = useContext(Ktx2LoaderContext);
|
||||
return useGLTF(
|
||||
url,
|
||||
false,
|
||||
true,
|
||||
(loader) => {
|
||||
if (ktx2Loader) loader.setKTX2Loader(ktx2Loader);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -30,15 +30,30 @@ import type { BossId, MemberId, PulseKind } from "../game/types";
|
||||
import { BossRoom } from "./BossRoom";
|
||||
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
||||
import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals";
|
||||
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, selectedGameAssetUrl, useGameGLTF } from "./GameAssetProvider";
|
||||
|
||||
const PARTY_MODEL_URLS: Record<MemberId, string> = {
|
||||
const PARTY_MODEL_LEGACY_URLS: Record<MemberId, string> = {
|
||||
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
|
||||
brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
|
||||
nia: new URL("../assets/game/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
|
||||
orin: new URL("../assets/game/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
|
||||
vale: new URL("../assets/game/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
|
||||
};
|
||||
const PARTY_WEAPON_URLS: Record<MemberId, { right: string; left?: string }> = {
|
||||
const PARTY_MODEL_OPTIMIZED_URLS: Record<MemberId, string> = {
|
||||
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid-uastc.glb", import.meta.url).href,
|
||||
brann: new URL("../assets/game/models/claudecraft/chars/players/knight-uastc.glb", import.meta.url).href,
|
||||
nia: new URL("../assets/game/models/claudecraft/chars/players/ranger-uastc.glb", import.meta.url).href,
|
||||
orin: new URL("../assets/game/models/claudecraft/chars/players/mage-uastc.glb", import.meta.url).href,
|
||||
vale: new URL("../assets/game/models/claudecraft/chars/players/rogue-uastc.glb", import.meta.url).href,
|
||||
};
|
||||
const PARTY_MODEL_URLS = Object.fromEntries(Object.keys(PARTY_MODEL_LEGACY_URLS).map((memberId) => [
|
||||
memberId,
|
||||
selectedGameAssetUrl(
|
||||
PARTY_MODEL_LEGACY_URLS[memberId as MemberId],
|
||||
PARTY_MODEL_OPTIMIZED_URLS[memberId as MemberId],
|
||||
),
|
||||
])) as Record<MemberId, string>;
|
||||
const PARTY_WEAPON_LEGACY_URLS: Record<MemberId, { right: string; left?: string }> = {
|
||||
aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
|
||||
brann: {
|
||||
right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
|
||||
@@ -54,6 +69,30 @@ const PARTY_WEAPON_URLS: Record<MemberId, { right: string; left?: string }> = {
|
||||
left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
|
||||
},
|
||||
};
|
||||
const PARTY_WEAPON_OPTIMIZED_URLS: Record<MemberId, { right: string; left?: string }> = {
|
||||
aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff-uastc.glb", import.meta.url).href },
|
||||
brann: {
|
||||
right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed-uastc.glb", import.meta.url).href,
|
||||
left: new URL("../assets/game/models/claudecraft/weapons/shield_badge-uastc.glb", import.meta.url).href,
|
||||
},
|
||||
nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed-uastc.glb", import.meta.url).href },
|
||||
orin: {
|
||||
right: new URL("../assets/game/models/claudecraft/weapons/adv_wand-uastc.glb", import.meta.url).href,
|
||||
left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open-uastc.glb", import.meta.url).href,
|
||||
},
|
||||
vale: {
|
||||
right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href,
|
||||
left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href,
|
||||
},
|
||||
};
|
||||
const PARTY_WEAPON_URLS = Object.fromEntries(Object.keys(PARTY_WEAPON_LEGACY_URLS).map((memberId) => {
|
||||
const legacy = PARTY_WEAPON_LEGACY_URLS[memberId as MemberId];
|
||||
const optimized = PARTY_WEAPON_OPTIMIZED_URLS[memberId as MemberId];
|
||||
return [memberId, {
|
||||
right: selectedGameAssetUrl(legacy.right, optimized.right),
|
||||
left: legacy.left && optimized.left ? selectedGameAssetUrl(legacy.left, optimized.left) : undefined,
|
||||
}];
|
||||
})) as Record<MemberId, { right: string; left?: string }>;
|
||||
const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 };
|
||||
const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
|
||||
aelia: "2H_Melee_Attack_Chop",
|
||||
@@ -227,12 +266,12 @@ function PartyCharacterModel({
|
||||
animationState: MutableRefObject<ActorAnimationState>;
|
||||
animationTrigger: MutableRefObject<number>;
|
||||
}) {
|
||||
const gltf = useGLTF(PARTY_MODEL_URLS[memberId], false, true);
|
||||
const gltf = useGameGLTF(PARTY_MODEL_URLS[memberId]);
|
||||
const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
|
||||
const loadout = PARTY_WEAPON_URLS[memberId];
|
||||
const grips = PARTY_WEAPON_GRIPS[memberId];
|
||||
const rightWeapon = useGLTF(loadout.right, false, true);
|
||||
const leftWeapon = useGLTF(loadout.left ?? loadout.right, false, true);
|
||||
const rightWeapon = useGameGLTF(loadout.right);
|
||||
const leftWeapon = useGameGLTF(loadout.left ?? loadout.right);
|
||||
const rightHandSlot = resolveRigNode(actorScene, "handslot.r");
|
||||
const leftHandSlot = resolveRigNode(actorScene, "handslot.l");
|
||||
const rightWeaponScene = useMemo(
|
||||
@@ -793,7 +832,8 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
const defeated = bossHp <= 0;
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const light = useRef<THREE.PointLight>(null);
|
||||
const gltf = useGLTF(config.url, false, true);
|
||||
const assetUrl = selectedGameAssetUrl(config.url, config.optimizedUrl ?? config.url);
|
||||
const gltf = useGameGLTF(assetUrl);
|
||||
const { model, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
|
||||
const { actions } = useAnimations(gltf.animations, model);
|
||||
const targetPosition = useMemo(() => new THREE.Vector3(), []);
|
||||
@@ -1184,23 +1224,27 @@ export function GameScene() {
|
||||
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
|
||||
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
|
||||
>
|
||||
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
|
||||
<BossRoom />
|
||||
<BossMechanicIndicators />
|
||||
<BarrierField />
|
||||
<TankAuraField />
|
||||
<Party />
|
||||
<BossActor />
|
||||
<RangedProjectiles />
|
||||
<CombatFx />
|
||||
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
||||
<GameAssetProvider>
|
||||
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
|
||||
<BossRoom />
|
||||
<BossMechanicIndicators />
|
||||
<BarrierField />
|
||||
<TankAuraField />
|
||||
<Party />
|
||||
<BossActor />
|
||||
<RangedProjectiles />
|
||||
<CombatFx />
|
||||
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
||||
</GameAssetProvider>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
|
||||
useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
|
||||
const loadout = PARTY_WEAPON_URLS[memberId];
|
||||
useGLTF.preload(loadout.right, false, true);
|
||||
if (loadout.left) useGLTF.preload(loadout.left, false, true);
|
||||
if (LEGACY_GAME_ASSETS_FORCED) {
|
||||
for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
|
||||
useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
|
||||
const loadout = PARTY_WEAPON_URLS[memberId];
|
||||
useGLTF.preload(loadout.right, false, true);
|
||||
if (loadout.left) useGLTF.preload(loadout.left, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user