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">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
Scene,
|
|
||||||
PerspectiveCamera,
|
|
||||||
WebGLRenderer,
|
|
||||||
AmbientLight,
|
AmbientLight,
|
||||||
HemisphereLight,
|
HemisphereLight,
|
||||||
Box3,
|
Box3,
|
||||||
LoadingManager,
|
|
||||||
Loader,
|
|
||||||
TextureLoader,
|
TextureLoader,
|
||||||
EquirectangularReflectionMapping,
|
EquirectangularReflectionMapping,
|
||||||
SRGBColorSpace,
|
SRGBColorSpace,
|
||||||
|
PerspectiveCamera,
|
||||||
type Texture,
|
type Texture,
|
||||||
} from "three";
|
} 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 type { AssetDto } from "@/native/AssetBinds.ts";
|
||||||
|
import { computed, ref, useTemplateRef, watch } from "vue";
|
||||||
import {
|
import {
|
||||||
computed,
|
createResourceTrackedObject,
|
||||||
onMounted,
|
createSceneObject,
|
||||||
onUnmounted,
|
provideThreeScene,
|
||||||
ref,
|
} from "@/components/three/useThreeScene.ts";
|
||||||
shallowRef,
|
import { useThreeRenderer } from "@/components/three/useThreeRenderer.ts";
|
||||||
toRaw,
|
import { useOrbitControls } from "@/components/three/useOrbitControls.ts";
|
||||||
useTemplateRef,
|
import ThreeScene from "@/components/three/ThreeScene.vue";
|
||||||
watch,
|
import { getModManUrl } from "@/meta/ModManUrl.ts";
|
||||||
} from "vue";
|
import { useGltfLoader } from "@/components/assets/xmodel/GltfLoader.ts";
|
||||||
import { useResizeObserver } from "@vueuse/core";
|
|
||||||
import { ThreeResourceTracker } from "@/components/assets/xmodel/ThreeResourceTracker.ts";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
asset: AssetDto;
|
asset: AssetDto;
|
||||||
zoneName: string;
|
zoneName: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const canvasWrapperRef = useTemplateRef<HTMLDivElement>("canvas-wrapper");
|
const { scene } = provideThreeScene();
|
||||||
const canvasRef = useTemplateRef<HTMLCanvasElement>("canvas");
|
const sceneRef = useTemplateRef("sceneRef");
|
||||||
|
const { camera } = useThreeRenderer(sceneRef);
|
||||||
const scene = new Scene();
|
const { controls } = useOrbitControls(sceneRef, camera);
|
||||||
const camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
||||||
|
|
||||||
const color = 0xffffff;
|
const color = 0xffffff;
|
||||||
const intensity = 1;
|
const intensity = 1;
|
||||||
@@ -51,90 +42,45 @@ const groundColor = 0xb97a20; // brownish orange
|
|||||||
const hemisphereLight = new HemisphereLight(skyColor, groundColor, intensity);
|
const hemisphereLight = new HemisphereLight(skyColor, groundColor, intensity);
|
||||||
scene.add(hemisphereLight);
|
scene.add(hemisphereLight);
|
||||||
|
|
||||||
camera.position.z = 3;
|
const modelUri = computed<string>(() =>
|
||||||
|
getModManUrl(
|
||||||
const IMAGE_REGEX = /\.\.[\\/]images[\\/](.+)\.(.+)$/m;
|
`/xmodel/glb?zone=${encodeURIComponent(props.zoneName)}&name=${encodeURIComponent(props.asset.name)}`,
|
||||||
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 model = shallowRef<GLTF | undefined>(undefined);
|
const model = createSceneObject();
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const resourceTracker = new ThreeResourceTracker();
|
|
||||||
const modelBounds = computed<Box3 | undefined>(() => {
|
const modelBounds = computed<Box3 | undefined>(() => {
|
||||||
const modelValue = model.value;
|
const modelValue = model.value;
|
||||||
if (!modelValue) return undefined;
|
if (!modelValue) return undefined;
|
||||||
|
|
||||||
const box = new Box3();
|
const box = new Box3();
|
||||||
box.expandByObject(modelValue.scene);
|
box.expandByObject(modelValue);
|
||||||
return box;
|
return box;
|
||||||
});
|
});
|
||||||
|
|
||||||
const loader = new TextureLoader();
|
const loader = new TextureLoader();
|
||||||
let skybox: Texture | undefined = undefined;
|
const skybox = createResourceTrackedObject<Texture>();
|
||||||
loader.loadAsync("/skybox/citrus_orchard_puresky.jpg").then((res) => {
|
loader.loadAsync("/skybox/citrus_orchard_puresky.jpg").then((res) => {
|
||||||
skybox = res;
|
skybox.value = res;
|
||||||
skybox.mapping = EquirectangularReflectionMapping;
|
skybox.value.mapping = EquirectangularReflectionMapping;
|
||||||
skybox.colorSpace = SRGBColorSpace;
|
skybox.value.colorSpace = SRGBColorSpace;
|
||||||
scene.background = skybox;
|
scene.background = skybox.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gltfLoader = useGltfLoader(() => props.zoneName);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
modelUri,
|
modelUri,
|
||||||
(uri) => {
|
(uri) => {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
gltfLoader
|
gltfLoader.value
|
||||||
.loadAsync(uri)
|
.loadAsync(uri)
|
||||||
.then((gltf) => (model.value = gltf))
|
.then((gltf) => (model.value = gltf.scene))
|
||||||
.finally(() => (isLoading.value = false));
|
.finally(() => (isLoading.value = false));
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
let renderer: WebGLRenderer | undefined = undefined;
|
|
||||||
let controls: OrbitControls | undefined;
|
|
||||||
|
|
||||||
function resetCameraPositionForObject() {
|
function resetCameraPositionForObject() {
|
||||||
const boundsValue = modelBounds.value;
|
const boundsValue = modelBounds.value;
|
||||||
if (!boundsValue) return;
|
if (!boundsValue) return;
|
||||||
@@ -147,103 +93,28 @@ function resetCameraPositionForObject() {
|
|||||||
const middleZ = boundsValue.min.z + sizeZ / 2;
|
const middleZ = boundsValue.min.z + sizeZ / 2;
|
||||||
|
|
||||||
const cameraX = Math.max(sizeY, sizeZ) / 2 + boundsValue.max.x;
|
const cameraX = Math.max(sizeY, sizeZ) / 2 + boundsValue.max.x;
|
||||||
camera.position.set(cameraX, middleY, middleZ);
|
camera.value.position.set(cameraX, middleY, middleZ);
|
||||||
camera.lookAt(middleX, middleY, middleZ);
|
camera.value.lookAt(middleX, middleY, middleZ);
|
||||||
|
|
||||||
camera.far = Math.max(cameraX + sizeX, sizeY, sizeZ, 1000) * 2;
|
if (camera instanceof PerspectiveCamera) {
|
||||||
camera.updateProjectionMatrix();
|
camera.far = Math.max(cameraX + sizeX, sizeY, sizeZ, 1000) * 2;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
if (controls) {
|
const controlsValue = controls.value;
|
||||||
controls.target.set(middleX, middleY, middleZ);
|
if (controlsValue) {
|
||||||
controls.update();
|
controlsValue.target.set(middleX, middleY, middleZ);
|
||||||
|
controlsValue.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(model, (newVal, oldVal) => {
|
watch(model, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
resourceTracker.refObject(newVal.scene);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldVal) {
|
|
||||||
scene.remove(toRaw(oldVal).scene);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newVal) {
|
|
||||||
scene.add(toRaw(newVal.scene));
|
|
||||||
resetCameraPositionForObject();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="preview-wrapper" ref="canvas-wrapper">
|
<ThreeScene ref="sceneRef" :loading="isLoading" />
|
||||||
<canvas class="preview" ref="canvas" />
|
|
||||||
<div v-if="isLoading" class="loading-overlay">
|
|
||||||
<span>Loading</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResourceTrackedType = Texture | Material | Object3D | BufferGeometry;
|
||||||
export class ThreeResourceTracker {
|
export class ThreeResourceTracker {
|
||||||
private readonly textures: Record<number, number>;
|
private readonly textures: Record<number, number>;
|
||||||
private readonly materials: Record<string, number>;
|
private readonly materials: Record<string, number>;
|
||||||
@@ -23,6 +24,30 @@ export class ThreeResourceTracker {
|
|||||||
this.objects = {};
|
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) {
|
refTexture(texture: Texture) {
|
||||||
if (!this.textures[texture.id]) {
|
if (!this.textures[texture.id]) {
|
||||||
this.textures[texture.id] = 1;
|
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">
|
<script setup lang="ts">
|
||||||
import XModelPreview from "@/components/assets/xmodel/XModelPreview.vue";
|
import XModelPreview from "@/components/assets/xmodel/XModelPreview.vue";
|
||||||
import type { AssetDto } from "@/native/AssetBinds.ts";
|
import type { AssetDto } from "@/native/AssetBinds.ts";
|
||||||
|
import ImagePreview from "@/components/assets/image/ImagePreview.vue";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
asset?: AssetDto;
|
asset?: AssetDto;
|
||||||
@@ -10,7 +11,8 @@ defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="preview">
|
<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>
|
<span v-else>No preview available</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ namespace image
|
|||||||
|
|
||||||
const auto faceCount = result->GetFaceCount();
|
const auto faceCount = result->GetFaceCount();
|
||||||
const auto mipCount = result->HasMipMaps() ? result->GetMipMapCount() : 1;
|
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++)
|
for (auto mipLevel = 0; mipLevel < mipCount; mipLevel++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ namespace image
|
|||||||
|
|
||||||
const auto faceCount = result->GetFaceCount();
|
const auto faceCount = result->GetFaceCount();
|
||||||
const auto mipCount = result->HasMipMaps() ? result->GetMipMapCount() : 1;
|
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++)
|
for (auto mipLevel = 0; mipLevel < mipCount; mipLevel++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ namespace image
|
|||||||
|
|
||||||
const auto faceCount = result->GetFaceCount();
|
const auto faceCount = result->GetFaceCount();
|
||||||
const auto mipCount = result->HasMipMaps() ? result->GetMipMapCount() : 1;
|
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++)
|
for (auto mipLevel = 0; mipLevel < mipCount; mipLevel++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ namespace image
|
|||||||
|
|
||||||
const auto faceCount = result->GetFaceCount();
|
const auto faceCount = result->GetFaceCount();
|
||||||
const auto mipCount = result->HasMipMaps() ? result->GetMipMapCount() : 1;
|
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++)
|
for (auto mipLevel = 0; mipLevel < mipCount; mipLevel++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ namespace image
|
|||||||
|
|
||||||
const auto faceCount = result->GetFaceCount();
|
const auto faceCount = result->GetFaceCount();
|
||||||
const auto mipCount = result->HasMipMaps() ? result->GetMipMapCount() : 1;
|
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++)
|
for (auto mipLevel = 0; mipLevel < mipCount; mipLevel++)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user