mirror of
https://github.com/Laupetin/OpenAssetTools.git
synced 2026-07-02 22:08:11 +00:00
feat: modman image preview (#862)
* fix: iwi rgb and rgba byte order wrong * chore: extract three logic from xmodel preview * feat: add preview for image assets * fix: bc1-5 decompressor assertion
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import ThreeScene from "@/components/three/ThreeScene.vue";
|
||||
import { computed, ref, useTemplateRef, watch } from "vue";
|
||||
import {
|
||||
AmbientLight,
|
||||
CompressedTexture,
|
||||
DoubleSide,
|
||||
HemisphereLight,
|
||||
Material,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
PlaneGeometry,
|
||||
type Texture,
|
||||
} from "three";
|
||||
import type { AssetDto } from "@/native/AssetBinds.ts";
|
||||
import {
|
||||
createResourceTrackedObject,
|
||||
createSceneObject,
|
||||
provideThreeScene,
|
||||
} from "@/components/three/useThreeScene.ts";
|
||||
import { useThreeRenderer } from "@/components/three/useThreeRenderer.ts";
|
||||
import { getModManUrl } from "@/meta/ModManUrl.ts";
|
||||
import { DDSLoader } from "three/examples/jsm/loaders/DDSLoader.js";
|
||||
|
||||
const props = defineProps<{
|
||||
asset: AssetDto;
|
||||
zoneName: string;
|
||||
}>();
|
||||
|
||||
const { scene } = provideThreeScene();
|
||||
|
||||
const color = 0xffffff;
|
||||
const intensity = 10;
|
||||
const light = new AmbientLight(color, intensity);
|
||||
scene.add(light);
|
||||
|
||||
const skyColor = 0xb1e1ff; // light blue
|
||||
const groundColor = 0xb97a20; // brownish orange
|
||||
const hemisphereLight = new HemisphereLight(skyColor, groundColor, intensity);
|
||||
scene.add(hemisphereLight);
|
||||
|
||||
const imageUri = computed<string>(() =>
|
||||
getModManUrl(
|
||||
`/image/dds?zone=${encodeURIComponent(props.zoneName)}&name=${encodeURIComponent(props.asset.name)}`,
|
||||
),
|
||||
);
|
||||
|
||||
const isLoading = ref(false);
|
||||
|
||||
const loader = new DDSLoader();
|
||||
const texture = createResourceTrackedObject<Texture>();
|
||||
watch(
|
||||
imageUri,
|
||||
(uri) => {
|
||||
isLoading.value = true;
|
||||
loader
|
||||
.loadAsync(uri)
|
||||
.then((loadedTexture: CompressedTexture) => {
|
||||
loadedTexture.needsUpdate = true;
|
||||
texture.value = loadedTexture;
|
||||
})
|
||||
.finally(() => (isLoading.value = false));
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const textureWidth = computed(() => texture.value?.width ?? 0);
|
||||
const textureHeight = computed(() => texture.value?.height ?? 0);
|
||||
|
||||
const sceneRef = useTemplateRef("sceneRef");
|
||||
const { camera } = useThreeRenderer(sceneRef, {
|
||||
cameraType: "orthographic",
|
||||
orthoAdjustForAspect: true,
|
||||
orthoWidth: () => textureWidth.value / 2,
|
||||
orthoHeight: () => textureHeight.value / 2,
|
||||
});
|
||||
camera.value.position.z = -1;
|
||||
camera.value.rotation.x = Math.PI;
|
||||
camera.value.updateProjectionMatrix();
|
||||
|
||||
const material = createResourceTrackedObject<Material>();
|
||||
watch(texture, (newValue) => {
|
||||
material.value = new MeshBasicMaterial({ map: newValue, transparent: true, side: DoubleSide });
|
||||
});
|
||||
|
||||
const textureMesh = createSceneObject<Mesh>();
|
||||
watch(material, (newValue) => {
|
||||
const newMesh = new Mesh(new PlaneGeometry(textureWidth.value, textureHeight.value), newValue);
|
||||
newMesh.position.x = 0;
|
||||
newMesh.position.y = 0;
|
||||
newMesh.position.z = 0;
|
||||
textureMesh.value = newMesh;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ThreeScene ref="sceneRef" :loading="isLoading" />
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
import { computed, type MaybeRefOrGetter, toValue } from "vue";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||
import { DDSLoader } from "three/examples/jsm/loaders/DDSLoader.js";
|
||||
import { getModManUrl } from "@/meta/ModManUrl.ts";
|
||||
import { Loader, LoadingManager } from "three";
|
||||
export { type GLTF } from "three/addons/loaders/GLTFLoader.js";
|
||||
|
||||
const IMAGE_REGEX = /\.\.[\\/]images[\\/](.+)\.(.+)$/m;
|
||||
class ProxyImageLoader extends Loader {
|
||||
private ddsLoader: DDSLoader;
|
||||
private readonly zoneName: string;
|
||||
|
||||
constructor(zoneName: string) {
|
||||
super();
|
||||
this.ddsLoader = new DDSLoader();
|
||||
this.zoneName = zoneName;
|
||||
}
|
||||
load(
|
||||
url: string,
|
||||
onLoad: (data: unknown) => void,
|
||||
onProgress?: (event: ProgressEvent) => void,
|
||||
onError?: (err: unknown) => void,
|
||||
) {
|
||||
const match = IMAGE_REGEX.exec(url);
|
||||
if (!match) {
|
||||
onError?.("invalid url");
|
||||
return;
|
||||
}
|
||||
|
||||
this.ddsLoader.load(
|
||||
getModManUrl(
|
||||
`/image/dds?zone=${encodeURIComponent(this.zoneName)}&name=${encodeURIComponent(match[1])}`,
|
||||
),
|
||||
onLoad,
|
||||
onProgress,
|
||||
onError,
|
||||
);
|
||||
}
|
||||
loadAsync(url: string, onProgress?: (event: ProgressEvent) => void): Promise<unknown> {
|
||||
const match = IMAGE_REGEX.exec(url);
|
||||
if (!match) {
|
||||
return Promise.reject("invalid url");
|
||||
}
|
||||
|
||||
return this.ddsLoader.loadAsync(
|
||||
getModManUrl(
|
||||
`/image/dds?zone=${encodeURIComponent(this.zoneName)}&name=${encodeURIComponent(match[1])}`,
|
||||
),
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function useGltfLoader(zoneName: MaybeRefOrGetter<string>) {
|
||||
return computed(() => {
|
||||
const manager = new LoadingManager();
|
||||
manager.addHandler(IMAGE_REGEX, new ProxyImageLoader(toValue(zoneName)));
|
||||
|
||||
return new GLTFLoader(manager);
|
||||
});
|
||||
}
|
||||
@@ -1,45 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Scene,
|
||||
PerspectiveCamera,
|
||||
WebGLRenderer,
|
||||
AmbientLight,
|
||||
HemisphereLight,
|
||||
Box3,
|
||||
LoadingManager,
|
||||
Loader,
|
||||
TextureLoader,
|
||||
EquirectangularReflectionMapping,
|
||||
SRGBColorSpace,
|
||||
PerspectiveCamera,
|
||||
type Texture,
|
||||
} from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
import { GLTFLoader, type GLTF } from "three/addons/loaders/GLTFLoader.js";
|
||||
import { DDSLoader } from "three/examples/jsm/loaders/DDSLoader.js";
|
||||
import type { AssetDto } from "@/native/AssetBinds.ts";
|
||||
import { computed, ref, useTemplateRef, watch } from "vue";
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
toRaw,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from "vue";
|
||||
import { useResizeObserver } from "@vueuse/core";
|
||||
import { ThreeResourceTracker } from "@/components/assets/xmodel/ThreeResourceTracker.ts";
|
||||
createResourceTrackedObject,
|
||||
createSceneObject,
|
||||
provideThreeScene,
|
||||
} from "@/components/three/useThreeScene.ts";
|
||||
import { useThreeRenderer } from "@/components/three/useThreeRenderer.ts";
|
||||
import { useOrbitControls } from "@/components/three/useOrbitControls.ts";
|
||||
import ThreeScene from "@/components/three/ThreeScene.vue";
|
||||
import { getModManUrl } from "@/meta/ModManUrl.ts";
|
||||
import { useGltfLoader } from "@/components/assets/xmodel/GltfLoader.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
asset: AssetDto;
|
||||
zoneName: string;
|
||||
}>();
|
||||
|
||||
const canvasWrapperRef = useTemplateRef<HTMLDivElement>("canvas-wrapper");
|
||||
const canvasRef = useTemplateRef<HTMLCanvasElement>("canvas");
|
||||
|
||||
const scene = new Scene();
|
||||
const camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
const { scene } = provideThreeScene();
|
||||
const sceneRef = useTemplateRef("sceneRef");
|
||||
const { camera } = useThreeRenderer(sceneRef);
|
||||
const { controls } = useOrbitControls(sceneRef, camera);
|
||||
|
||||
const color = 0xffffff;
|
||||
const intensity = 1;
|
||||
@@ -51,90 +42,45 @@ const groundColor = 0xb97a20; // brownish orange
|
||||
const hemisphereLight = new HemisphereLight(skyColor, groundColor, intensity);
|
||||
scene.add(hemisphereLight);
|
||||
|
||||
camera.position.z = 3;
|
||||
|
||||
const IMAGE_REGEX = /\.\.[\\/]images[\\/](.+)\.(.+)$/m;
|
||||
class ProxyImageLoader extends Loader {
|
||||
private ddsLoader: DDSLoader;
|
||||
constructor() {
|
||||
super();
|
||||
this.ddsLoader = new DDSLoader();
|
||||
}
|
||||
load(
|
||||
url: string,
|
||||
onLoad: (data: unknown) => void,
|
||||
onProgress?: (event: ProgressEvent) => void,
|
||||
onError?: (err: unknown) => void,
|
||||
) {
|
||||
const match = IMAGE_REGEX.exec(url);
|
||||
if (!match) {
|
||||
onError?.("invalid url");
|
||||
return;
|
||||
}
|
||||
|
||||
this.ddsLoader.load(
|
||||
`modman://localhost/image/dds?zone=${encodeURIComponent(props.zoneName)}&name=${encodeURIComponent(match[1])}`,
|
||||
onLoad,
|
||||
onProgress,
|
||||
onError,
|
||||
);
|
||||
}
|
||||
loadAsync(url: string, onProgress?: (event: ProgressEvent) => void): Promise<unknown> {
|
||||
const match = IMAGE_REGEX.exec(url);
|
||||
if (!match) {
|
||||
return Promise.reject("invalid url");
|
||||
}
|
||||
|
||||
return this.ddsLoader.loadAsync(
|
||||
`modman://localhost/image/dds?zone=${encodeURIComponent(props.zoneName)}&name=${encodeURIComponent(match[1])}`,
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
}
|
||||
const manager = new LoadingManager();
|
||||
manager.addHandler(IMAGE_REGEX, new ProxyImageLoader());
|
||||
|
||||
const gltfLoader = new GLTFLoader(manager);
|
||||
const modelUri = computed<string>(
|
||||
() =>
|
||||
`modman://localhost/xmodel/glb?zone=${encodeURIComponent(props.zoneName)}&name=${encodeURIComponent(props.asset.name)}`,
|
||||
const modelUri = computed<string>(() =>
|
||||
getModManUrl(
|
||||
`/xmodel/glb?zone=${encodeURIComponent(props.zoneName)}&name=${encodeURIComponent(props.asset.name)}`,
|
||||
),
|
||||
);
|
||||
const model = shallowRef<GLTF | undefined>(undefined);
|
||||
const model = createSceneObject();
|
||||
const isLoading = ref(false);
|
||||
const resourceTracker = new ThreeResourceTracker();
|
||||
const modelBounds = computed<Box3 | undefined>(() => {
|
||||
const modelValue = model.value;
|
||||
if (!modelValue) return undefined;
|
||||
|
||||
const box = new Box3();
|
||||
box.expandByObject(modelValue.scene);
|
||||
box.expandByObject(modelValue);
|
||||
return box;
|
||||
});
|
||||
|
||||
const loader = new TextureLoader();
|
||||
let skybox: Texture | undefined = undefined;
|
||||
const skybox = createResourceTrackedObject<Texture>();
|
||||
loader.loadAsync("/skybox/citrus_orchard_puresky.jpg").then((res) => {
|
||||
skybox = res;
|
||||
skybox.mapping = EquirectangularReflectionMapping;
|
||||
skybox.colorSpace = SRGBColorSpace;
|
||||
scene.background = skybox;
|
||||
skybox.value = res;
|
||||
skybox.value.mapping = EquirectangularReflectionMapping;
|
||||
skybox.value.colorSpace = SRGBColorSpace;
|
||||
scene.background = skybox.value;
|
||||
});
|
||||
|
||||
const gltfLoader = useGltfLoader(() => props.zoneName);
|
||||
|
||||
watch(
|
||||
modelUri,
|
||||
(uri) => {
|
||||
isLoading.value = true;
|
||||
gltfLoader
|
||||
gltfLoader.value
|
||||
.loadAsync(uri)
|
||||
.then((gltf) => (model.value = gltf))
|
||||
.then((gltf) => (model.value = gltf.scene))
|
||||
.finally(() => (isLoading.value = false));
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
let renderer: WebGLRenderer | undefined = undefined;
|
||||
let controls: OrbitControls | undefined;
|
||||
|
||||
function resetCameraPositionForObject() {
|
||||
const boundsValue = modelBounds.value;
|
||||
if (!boundsValue) return;
|
||||
@@ -147,103 +93,28 @@ function resetCameraPositionForObject() {
|
||||
const middleZ = boundsValue.min.z + sizeZ / 2;
|
||||
|
||||
const cameraX = Math.max(sizeY, sizeZ) / 2 + boundsValue.max.x;
|
||||
camera.position.set(cameraX, middleY, middleZ);
|
||||
camera.lookAt(middleX, middleY, middleZ);
|
||||
camera.value.position.set(cameraX, middleY, middleZ);
|
||||
camera.value.lookAt(middleX, middleY, middleZ);
|
||||
|
||||
camera.far = Math.max(cameraX + sizeX, sizeY, sizeZ, 1000) * 2;
|
||||
camera.updateProjectionMatrix();
|
||||
if (camera instanceof PerspectiveCamera) {
|
||||
camera.far = Math.max(cameraX + sizeX, sizeY, sizeZ, 1000) * 2;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
if (controls) {
|
||||
controls.target.set(middleX, middleY, middleZ);
|
||||
controls.update();
|
||||
const controlsValue = controls.value;
|
||||
if (controlsValue) {
|
||||
controlsValue.target.set(middleX, middleY, middleZ);
|
||||
controlsValue.update();
|
||||
}
|
||||
}
|
||||
|
||||
watch(model, (newVal, oldVal) => {
|
||||
watch(model, (newVal) => {
|
||||
if (newVal) {
|
||||
resourceTracker.refObject(newVal.scene);
|
||||
}
|
||||
|
||||
if (oldVal) {
|
||||
scene.remove(toRaw(oldVal).scene);
|
||||
}
|
||||
|
||||
if (newVal) {
|
||||
scene.add(toRaw(newVal.scene));
|
||||
resetCameraPositionForObject();
|
||||
}
|
||||
|
||||
if (oldVal) {
|
||||
resourceTracker.unrefObject(toRaw(oldVal).scene);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
renderer = new WebGLRenderer({ canvas: canvasRef.value! });
|
||||
const canvasWrapperBounds = canvasWrapperRef.value!.getBoundingClientRect();
|
||||
renderer.setSize(canvasWrapperBounds.width, canvasWrapperBounds.height);
|
||||
|
||||
function animate() {
|
||||
renderer!.render(scene, camera);
|
||||
}
|
||||
renderer.setAnimationLoop(animate);
|
||||
|
||||
controls = new OrbitControls(camera, canvasRef.value!);
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (model.value) {
|
||||
resourceTracker.unrefObject(toRaw(model.value).scene);
|
||||
}
|
||||
if (skybox) {
|
||||
skybox.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
useResizeObserver(canvasWrapperRef, () => {
|
||||
const canvasWrapperBounds = canvasWrapperRef.value!.getBoundingClientRect();
|
||||
renderer?.setSize(canvasWrapperBounds.width, canvasWrapperBounds.height);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="preview-wrapper" ref="canvas-wrapper">
|
||||
<canvas class="preview" ref="canvas" />
|
||||
<div v-if="isLoading" class="loading-overlay">
|
||||
<span>Loading</span>
|
||||
</div>
|
||||
</div>
|
||||
<ThreeScene ref="sceneRef" :loading="isLoading" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.preview-wrapper {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.loading-overlay {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
transition: opacity ease-in-out 500ms;
|
||||
}
|
||||
</style>
|
||||
|
||||
+25
@@ -10,6 +10,7 @@ function getMaterialsOfObject(object: Object3D) {
|
||||
return [];
|
||||
}
|
||||
|
||||
export type ResourceTrackedType = Texture | Material | Object3D | BufferGeometry;
|
||||
export class ThreeResourceTracker {
|
||||
private readonly textures: Record<number, number>;
|
||||
private readonly materials: Record<string, number>;
|
||||
@@ -23,6 +24,30 @@ export class ThreeResourceTracker {
|
||||
this.objects = {};
|
||||
}
|
||||
|
||||
ref(value: ResourceTrackedType) {
|
||||
if (value instanceof Texture) {
|
||||
this.refTexture(value);
|
||||
} else if (value instanceof Material) {
|
||||
this.refMaterial(value);
|
||||
} else if (value instanceof BufferGeometry) {
|
||||
this.refGeometry(value);
|
||||
} else if (value instanceof Object3D) {
|
||||
this.refObject(value);
|
||||
}
|
||||
}
|
||||
|
||||
unref(value: ResourceTrackedType) {
|
||||
if (value instanceof Texture) {
|
||||
this.unrefTexture(value);
|
||||
} else if (value instanceof Material) {
|
||||
this.unrefMaterial(value);
|
||||
} else if (value instanceof BufferGeometry) {
|
||||
this.unrefGeometry(value);
|
||||
} else if (value instanceof Object3D) {
|
||||
this.unrefObject(value);
|
||||
}
|
||||
}
|
||||
|
||||
refTexture(texture: Texture) {
|
||||
if (!this.textures[texture.id]) {
|
||||
this.textures[texture.id] = 1;
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from "vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
}>(),
|
||||
{ loading: false },
|
||||
);
|
||||
|
||||
const canvasWrapperRef = useTemplateRef<HTMLDivElement>("canvas-wrapper");
|
||||
const canvasRef = useTemplateRef<HTMLCanvasElement>("canvas");
|
||||
|
||||
defineExpose({ canvasRef, canvasWrapperRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="three-scene" ref="canvas-wrapper">
|
||||
<canvas class="three-canvas" ref="canvas" />
|
||||
|
||||
<slot />
|
||||
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<span>Loading</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.three-scene {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.three-canvas {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.loading-overlay {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
transition: opacity ease-in-out 500ms;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type ComponentInstance, type MaybeRefOrGetter, shallowRef, toValue, watch } from "vue";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
import type { Camera } from "three";
|
||||
import ThreeScene from "@/components/three/ThreeScene.vue";
|
||||
|
||||
export function useOrbitControls(
|
||||
sceneComponent: MaybeRefOrGetter<ComponentInstance<typeof ThreeScene> | null>,
|
||||
camera: MaybeRefOrGetter<Camera>,
|
||||
) {
|
||||
const controls = shallowRef<OrbitControls | undefined>(undefined);
|
||||
|
||||
watch(
|
||||
() => toValue(sceneComponent)?.canvasRef ?? null,
|
||||
(canvasRefValue) => {
|
||||
if (canvasRefValue) {
|
||||
const cameraValue = toValue(camera);
|
||||
controls.value = new OrbitControls(cameraValue, canvasRefValue);
|
||||
controls.value.target.set(0, 0, 0);
|
||||
controls.value.update();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return { controls };
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
type ComponentInstance,
|
||||
computed,
|
||||
type MaybeRefOrGetter,
|
||||
ref,
|
||||
shallowRef,
|
||||
toValue,
|
||||
watch,
|
||||
} from "vue";
|
||||
import { OrthographicCamera, PerspectiveCamera, WebGLRenderer } from "three";
|
||||
import { useResizeObserver } from "@vueuse/core";
|
||||
import ThreeScene from "@/components/three/ThreeScene.vue";
|
||||
import { useThreeSceneOrThrow } from "@/components/three/useThreeScene.ts";
|
||||
|
||||
export type CameraType = PerspectiveCamera | OrthographicCamera;
|
||||
export interface UseThreeRendererOptions {
|
||||
cameraType?: "perspective" | "orthographic";
|
||||
fov?: MaybeRefOrGetter<number>;
|
||||
orthoAdjustForAspect?: boolean;
|
||||
orthoWidth?: MaybeRefOrGetter<number>;
|
||||
orthoHeight?: MaybeRefOrGetter<number>;
|
||||
zNear?: MaybeRefOrGetter<number>;
|
||||
zFar?: MaybeRefOrGetter<number>;
|
||||
}
|
||||
|
||||
export function useThreeRenderer(
|
||||
sceneComponent: MaybeRefOrGetter<ComponentInstance<typeof ThreeScene> | null>,
|
||||
options?: UseThreeRendererOptions,
|
||||
) {
|
||||
const cameraType = options?.cameraType ?? "perspective";
|
||||
const fov = options?.fov ?? 75;
|
||||
const zNear = options?.zNear ?? 0.1;
|
||||
const zFar = options?.zFar ?? 1000;
|
||||
const orthoWidth = options?.orthoWidth ?? 1;
|
||||
const orthoHeight = options?.orthoHeight ?? 1;
|
||||
|
||||
const { scene } = useThreeSceneOrThrow();
|
||||
const canvas = computed(() => toValue(sceneComponent)?.canvasRef ?? null);
|
||||
const canvasWrapper = computed(() => toValue(sceneComponent)?.canvasWrapperRef ?? null);
|
||||
|
||||
function createNewCamera() {
|
||||
if (cameraType === "perspective") {
|
||||
let aspect: number = 1;
|
||||
const canvasWrapperValue = canvasWrapper.value;
|
||||
if (canvasWrapperValue) {
|
||||
const canvasWrapperBounds = canvasWrapperValue.getBoundingClientRect();
|
||||
aspect = canvasWrapperBounds.width / canvasWrapperBounds.height;
|
||||
}
|
||||
|
||||
return new PerspectiveCamera(toValue(fov), aspect, toValue(zNear), toValue(zFar));
|
||||
}
|
||||
|
||||
const orthoWidthValue = toValue(orthoWidth);
|
||||
const orthoHeightValue = toValue(orthoHeight);
|
||||
return new OrthographicCamera(
|
||||
orthoWidthValue / -2,
|
||||
orthoWidthValue / 2,
|
||||
orthoHeightValue / 2,
|
||||
orthoHeightValue / -2,
|
||||
toValue(zNear),
|
||||
toValue(zFar),
|
||||
);
|
||||
}
|
||||
|
||||
const camera = shallowRef<CameraType>(createNewCamera());
|
||||
const aspect = ref(1);
|
||||
let renderer: WebGLRenderer | undefined = undefined;
|
||||
|
||||
watch(
|
||||
() => [canvas.value, canvasWrapper.value],
|
||||
([canvasValue, canvasWrapperValue]) => {
|
||||
if (!canvasValue || !canvasWrapperValue) return;
|
||||
|
||||
renderer = new WebGLRenderer({ canvas: canvasValue, alpha: true });
|
||||
const canvasWrapperBounds = canvasWrapperValue.getBoundingClientRect();
|
||||
renderer.setSize(canvasWrapperBounds.width, canvasWrapperBounds.height);
|
||||
aspect.value = canvasWrapperBounds.width / canvasWrapperBounds.height;
|
||||
|
||||
if (camera.value instanceof PerspectiveCamera) {
|
||||
camera.value.aspect = aspect.value;
|
||||
}
|
||||
|
||||
function animate() {
|
||||
renderer!.render(toValue(scene), camera.value);
|
||||
}
|
||||
renderer.setAnimationLoop(animate);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
useResizeObserver(canvasWrapper, () => {
|
||||
const canvasWrapperBounds = canvasWrapper.value!.getBoundingClientRect();
|
||||
renderer?.setSize(canvasWrapperBounds.width, canvasWrapperBounds.height);
|
||||
aspect.value = canvasWrapperBounds.width / canvasWrapperBounds.height;
|
||||
});
|
||||
|
||||
watch(aspect, (newValue) => {
|
||||
if (camera.value instanceof PerspectiveCamera) {
|
||||
camera.value.aspect = newValue;
|
||||
camera.value.updateProjectionMatrix();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => toValue(fov),
|
||||
(fovValue) => {
|
||||
const cameraValue = camera.value;
|
||||
if (cameraValue instanceof PerspectiveCamera) {
|
||||
cameraValue.fov = fovValue;
|
||||
cameraValue.updateProjectionMatrix();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (options?.orthoAdjustForAspect === true) {
|
||||
watch(
|
||||
() => [toValue(orthoWidth), toValue(orthoHeight), aspect.value],
|
||||
([orthoWidthValue, orthoHeightValue, aspect]) => {
|
||||
const cameraValue = camera.value;
|
||||
|
||||
if (orthoWidthValue > orthoHeightValue) {
|
||||
orthoHeightValue = orthoWidthValue / aspect;
|
||||
} else {
|
||||
orthoWidthValue = orthoHeightValue * aspect;
|
||||
}
|
||||
|
||||
if (cameraValue instanceof OrthographicCamera) {
|
||||
cameraValue.left = -orthoWidthValue;
|
||||
cameraValue.right = orthoWidthValue;
|
||||
cameraValue.top = orthoHeightValue;
|
||||
cameraValue.bottom = -orthoHeightValue;
|
||||
cameraValue.updateProjectionMatrix();
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
watch(
|
||||
() => [toValue(orthoWidth), toValue(orthoHeight)],
|
||||
([orthoWidthValue, orthoHeightValue]) => {
|
||||
const cameraValue = camera.value;
|
||||
if (cameraValue instanceof OrthographicCamera) {
|
||||
cameraValue.left = -orthoWidthValue;
|
||||
cameraValue.right = orthoWidthValue;
|
||||
cameraValue.top = orthoHeightValue;
|
||||
cameraValue.bottom = -orthoHeightValue;
|
||||
cameraValue.updateProjectionMatrix();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => toValue(zNear),
|
||||
(nearValue) => {
|
||||
camera.value.near = nearValue;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => toValue(zFar),
|
||||
(farValue) => {
|
||||
camera.value.far = farValue;
|
||||
},
|
||||
);
|
||||
|
||||
return { camera };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createInjectionState } from "@vueuse/core";
|
||||
import { type Object3D, Scene } from "three";
|
||||
import {
|
||||
type ResourceTrackedType,
|
||||
ThreeResourceTracker,
|
||||
} from "@/components/three/ThreeResourceTracker.ts";
|
||||
import { onScopeDispose, shallowRef, watch } from "vue";
|
||||
|
||||
export const [provideThreeScene, useThreeScene] = createInjectionState(() => {
|
||||
const scene = new Scene();
|
||||
const resourceTracker = new ThreeResourceTracker();
|
||||
|
||||
return { scene, resourceTracker };
|
||||
});
|
||||
|
||||
export function useThreeSceneOrThrow() {
|
||||
const value = useThreeScene();
|
||||
if (!value) throw new Error("ThreeScene must be provided");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createResourceTrackedObject<T extends ResourceTrackedType>(initialValue?: T) {
|
||||
const { resourceTracker } = useThreeSceneOrThrow();
|
||||
const refObj = shallowRef<T | undefined>(initialValue);
|
||||
|
||||
watch(
|
||||
refObj,
|
||||
(newValue, oldValue) => {
|
||||
if (newValue === oldValue) return;
|
||||
|
||||
if (newValue) {
|
||||
resourceTracker.ref(newValue);
|
||||
}
|
||||
|
||||
if (oldValue) {
|
||||
resourceTracker.unref(oldValue);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onScopeDispose(() => {
|
||||
const remainingValue = refObj.value;
|
||||
if (remainingValue) {
|
||||
resourceTracker.unref(remainingValue);
|
||||
refObj.value = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
return refObj;
|
||||
}
|
||||
|
||||
export function createSceneObject<T extends Object3D>(initialValue?: T) {
|
||||
const { scene } = useThreeSceneOrThrow();
|
||||
const refObj = createResourceTrackedObject<T>(initialValue);
|
||||
|
||||
watch(refObj, (newValue, oldValue) => {
|
||||
if (newValue) {
|
||||
scene.add(newValue);
|
||||
}
|
||||
if (oldValue) {
|
||||
scene.remove(oldValue);
|
||||
}
|
||||
});
|
||||
|
||||
return refObj;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function getModManUrl(path: string) {
|
||||
return new URL(path, "modman://localhost").toString();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import XModelPreview from "@/components/assets/xmodel/XModelPreview.vue";
|
||||
import type { AssetDto } from "@/native/AssetBinds.ts";
|
||||
import ImagePreview from "@/components/assets/image/ImagePreview.vue";
|
||||
|
||||
defineProps<{
|
||||
asset?: AssetDto;
|
||||
@@ -10,7 +11,8 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<div class="preview">
|
||||
<XModelPreview v-if="asset?.type === 'xmodel'" :asset :zone-name />
|
||||
<ImagePreview v-if="asset?.type === 'image'" :asset :zone-name />
|
||||
<XModelPreview v-else-if="asset?.type === 'xmodel'" :asset :zone-name />
|
||||
<span v-else>No preview available</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -349,7 +349,7 @@ namespace image
|
||||
|
||||
const auto faceCount = result->GetFaceCount();
|
||||
const auto mipCount = result->HasMipMaps() ? result->GetMipMapCount() : 1;
|
||||
assert(mipCount == input.HasMipMaps() ? input.GetMipMapCount() : 1);
|
||||
assert(mipCount == (input.HasMipMaps() ? input.GetMipMapCount() : 1));
|
||||
|
||||
for (auto mipLevel = 0; mipLevel < mipCount; mipLevel++)
|
||||
{
|
||||
|
||||
@@ -315,7 +315,7 @@ namespace image
|
||||
|
||||
const auto faceCount = result->GetFaceCount();
|
||||
const auto mipCount = result->HasMipMaps() ? result->GetMipMapCount() : 1;
|
||||
assert(mipCount == input.HasMipMaps() ? input.GetMipMapCount() : 1);
|
||||
assert(mipCount == (input.HasMipMaps() ? input.GetMipMapCount() : 1));
|
||||
|
||||
for (auto mipLevel = 0; mipLevel < mipCount; mipLevel++)
|
||||
{
|
||||
|
||||
@@ -338,7 +338,7 @@ namespace image
|
||||
|
||||
const auto faceCount = result->GetFaceCount();
|
||||
const auto mipCount = result->HasMipMaps() ? result->GetMipMapCount() : 1;
|
||||
assert(mipCount == input.HasMipMaps() ? input.GetMipMapCount() : 1);
|
||||
assert(mipCount == (input.HasMipMaps() ? input.GetMipMapCount() : 1));
|
||||
|
||||
for (auto mipLevel = 0; mipLevel < mipCount; mipLevel++)
|
||||
{
|
||||
|
||||
@@ -180,7 +180,7 @@ namespace image
|
||||
|
||||
const auto faceCount = result->GetFaceCount();
|
||||
const auto mipCount = result->HasMipMaps() ? result->GetMipMapCount() : 1;
|
||||
assert(mipCount == input.HasMipMaps() ? input.GetMipMapCount() : 1);
|
||||
assert(mipCount == (input.HasMipMaps() ? input.GetMipMapCount() : 1));
|
||||
|
||||
for (auto mipLevel = 0; mipLevel < mipCount; mipLevel++)
|
||||
{
|
||||
|
||||
@@ -271,7 +271,7 @@ namespace image
|
||||
|
||||
const auto faceCount = result->GetFaceCount();
|
||||
const auto mipCount = result->HasMipMaps() ? result->GetMipMapCount() : 1;
|
||||
assert(mipCount == input.HasMipMaps() ? input.GetMipMapCount() : 1);
|
||||
assert(mipCount == (input.HasMipMaps() ? input.GetMipMapCount() : 1));
|
||||
|
||||
for (auto mipLevel = 0; mipLevel < mipCount; mipLevel++)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user