From 4017f084a8e66add59935517f60b9b67f408fd16 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 28 Jun 2026 20:34:09 +0200 Subject: [PATCH] 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 --- .../components/assets/image/ImagePreview.vue | 97 ++++++++ .../components/assets/xmodel/GltfLoader.ts | 61 +++++ .../assets/xmodel/XModelPreview.vue | 213 ++++-------------- .../xmodel => three}/ThreeResourceTracker.ts | 25 ++ .../src/components/three/ThreeScene.vue | 58 +++++ .../src/components/three/useOrbitControls.ts | 26 +++ .../src/components/three/useThreeRenderer.ts | 167 ++++++++++++++ .../src/components/three/useThreeScene.ts | 67 ++++++ src/ModManUi/src/meta/ModManUrl.ts | 3 + .../components/InspectPreview.vue | 4 +- .../Image/Compression/DecompressorBc1.cpp | 2 +- .../Image/Compression/DecompressorBc2.cpp | 2 +- .../Image/Compression/DecompressorBc3.cpp | 2 +- .../Image/Compression/DecompressorBc4.cpp | 2 +- .../Image/Compression/DecompressorBc5.cpp | 2 +- 15 files changed, 554 insertions(+), 177 deletions(-) create mode 100644 src/ModManUi/src/components/assets/image/ImagePreview.vue create mode 100644 src/ModManUi/src/components/assets/xmodel/GltfLoader.ts rename src/ModManUi/src/components/{assets/xmodel => three}/ThreeResourceTracker.ts (85%) create mode 100644 src/ModManUi/src/components/three/ThreeScene.vue create mode 100644 src/ModManUi/src/components/three/useOrbitControls.ts create mode 100644 src/ModManUi/src/components/three/useThreeRenderer.ts create mode 100644 src/ModManUi/src/components/three/useThreeScene.ts create mode 100644 src/ModManUi/src/meta/ModManUrl.ts diff --git a/src/ModManUi/src/components/assets/image/ImagePreview.vue b/src/ModManUi/src/components/assets/image/ImagePreview.vue new file mode 100644 index 00000000..30063a51 --- /dev/null +++ b/src/ModManUi/src/components/assets/image/ImagePreview.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/ModManUi/src/components/assets/xmodel/GltfLoader.ts b/src/ModManUi/src/components/assets/xmodel/GltfLoader.ts new file mode 100644 index 00000000..cf3638b7 --- /dev/null +++ b/src/ModManUi/src/components/assets/xmodel/GltfLoader.ts @@ -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 { + 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) { + return computed(() => { + const manager = new LoadingManager(); + manager.addHandler(IMAGE_REGEX, new ProxyImageLoader(toValue(zoneName))); + + return new GLTFLoader(manager); + }); +} diff --git a/src/ModManUi/src/components/assets/xmodel/XModelPreview.vue b/src/ModManUi/src/components/assets/xmodel/XModelPreview.vue index 1f82db69..e4413ed9 100644 --- a/src/ModManUi/src/components/assets/xmodel/XModelPreview.vue +++ b/src/ModManUi/src/components/assets/xmodel/XModelPreview.vue @@ -1,45 +1,36 @@ - - diff --git a/src/ModManUi/src/components/assets/xmodel/ThreeResourceTracker.ts b/src/ModManUi/src/components/three/ThreeResourceTracker.ts similarity index 85% rename from src/ModManUi/src/components/assets/xmodel/ThreeResourceTracker.ts rename to src/ModManUi/src/components/three/ThreeResourceTracker.ts index b5cc8548..d355b80b 100644 --- a/src/ModManUi/src/components/assets/xmodel/ThreeResourceTracker.ts +++ b/src/ModManUi/src/components/three/ThreeResourceTracker.ts @@ -10,6 +10,7 @@ function getMaterialsOfObject(object: Object3D) { return []; } +export type ResourceTrackedType = Texture | Material | Object3D | BufferGeometry; export class ThreeResourceTracker { private readonly textures: Record; private readonly materials: Record; @@ -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; diff --git a/src/ModManUi/src/components/three/ThreeScene.vue b/src/ModManUi/src/components/three/ThreeScene.vue new file mode 100644 index 00000000..864d6d9e --- /dev/null +++ b/src/ModManUi/src/components/three/ThreeScene.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/src/ModManUi/src/components/three/useOrbitControls.ts b/src/ModManUi/src/components/three/useOrbitControls.ts new file mode 100644 index 00000000..45376e5f --- /dev/null +++ b/src/ModManUi/src/components/three/useOrbitControls.ts @@ -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 | null>, + camera: MaybeRefOrGetter, +) { + const controls = shallowRef(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 }; +} diff --git a/src/ModManUi/src/components/three/useThreeRenderer.ts b/src/ModManUi/src/components/three/useThreeRenderer.ts new file mode 100644 index 00000000..77323aa2 --- /dev/null +++ b/src/ModManUi/src/components/three/useThreeRenderer.ts @@ -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; + orthoAdjustForAspect?: boolean; + orthoWidth?: MaybeRefOrGetter; + orthoHeight?: MaybeRefOrGetter; + zNear?: MaybeRefOrGetter; + zFar?: MaybeRefOrGetter; +} + +export function useThreeRenderer( + sceneComponent: MaybeRefOrGetter | 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(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 }; +} diff --git a/src/ModManUi/src/components/three/useThreeScene.ts b/src/ModManUi/src/components/three/useThreeScene.ts new file mode 100644 index 00000000..e2421ea7 --- /dev/null +++ b/src/ModManUi/src/components/three/useThreeScene.ts @@ -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(initialValue?: T) { + const { resourceTracker } = useThreeSceneOrThrow(); + const refObj = shallowRef(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(initialValue?: T) { + const { scene } = useThreeSceneOrThrow(); + const refObj = createResourceTrackedObject(initialValue); + + watch(refObj, (newValue, oldValue) => { + if (newValue) { + scene.add(newValue); + } + if (oldValue) { + scene.remove(oldValue); + } + }); + + return refObj; +} diff --git a/src/ModManUi/src/meta/ModManUrl.ts b/src/ModManUi/src/meta/ModManUrl.ts new file mode 100644 index 00000000..5fa7f035 --- /dev/null +++ b/src/ModManUi/src/meta/ModManUrl.ts @@ -0,0 +1,3 @@ +export function getModManUrl(path: string) { + return new URL(path, "modman://localhost").toString(); +} diff --git a/src/ModManUi/src/view/inspect_details/components/InspectPreview.vue b/src/ModManUi/src/view/inspect_details/components/InspectPreview.vue index 7906fd6e..e5eafa09 100644 --- a/src/ModManUi/src/view/inspect_details/components/InspectPreview.vue +++ b/src/ModManUi/src/view/inspect_details/components/InspectPreview.vue @@ -1,6 +1,7 @@