feat(assets): load 3d models as well

This commit is contained in:
2025-12-17 18:00:11 +01:00
parent 2f16f382e5
commit fbcc7703c9
7 changed files with 113 additions and 78 deletions

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
const { loaded, total } = useAssets();
</script>
<template>
<p>{{ loaded }} / {{ total }}</p>
</template>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { useLoop, useTresContext } from "@tresjs/core"; import { useLoop, useTresContext } from "@tresjs/core";
import * as THREE from "three"; import * as THREE from "three";
@@ -8,12 +7,9 @@ const props = defineProps<{
bottomScreenCanvas: HTMLCanvasElement | null; bottomScreenCanvas: HTMLCanvasElement | null;
}>(); }>();
const { state: model } = useLoader( const { assets } = useAssets();
GLTFLoader,
"/models/nintendo-ds/scene.gltf",
);
const scene = computed(() => model.value?.scene); const model = assets.nintendoDs.scene.clone(true);
let topScreenTexture: THREE.CanvasTexture | null = null; let topScreenTexture: THREE.CanvasTexture | null = null;
let bottomScreenTexture: THREE.CanvasTexture | null = null; let bottomScreenTexture: THREE.CanvasTexture | null = null;
@@ -43,6 +39,15 @@ const requireMesh = (key: string): THREE.Mesh => {
const { camera, renderer } = useTresContext(); const { camera, renderer } = useTresContext();
model.scale.set(100, 100, 100);
meshes.clear();
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
meshes.set(child.name, child);
}
});
watch( watch(
() => [props.topScreenCanvas, props.bottomScreenCanvas], () => [props.topScreenCanvas, props.bottomScreenCanvas],
() => { () => {
@@ -61,41 +66,22 @@ watch(
bottomScreenTexture.flipY = false; bottomScreenTexture.flipY = false;
bottomScreenTexture.repeat.set(1, 1024 / 532); bottomScreenTexture.repeat.set(1, 1024 / 532);
bottomScreenTexture.offset.set(0, -1024 / 532 + 1); bottomScreenTexture.offset.set(0, -1024 / 532 + 1);
requireMesh(TOP_SCREEN).material = new THREE.MeshStandardMaterial({
map: topScreenTexture,
emissive: new THREE.Color(0x222222),
emissiveIntensity: 0.5,
});
requireMesh(BOTTOM_SCREEN).material = new THREE.MeshStandardMaterial({
map: bottomScreenTexture,
emissive: new THREE.Color(0x222222),
emissiveIntensity: 0.5,
});
}, },
{ immediate: true }, { immediate: true },
); );
watch(scene, () => {
if (!scene.value) return;
meshes.clear();
scene.value.scale.set(100, 100, 100);
scene.value.traverse((child) => {
if (child instanceof THREE.Mesh) {
meshes.set(child.name, child);
}
});
if (!topScreenTexture || !bottomScreenTexture)
throw new Error(
"topScreenTexture and bottomScreenTexture should be initialized",
);
requireMesh(TOP_SCREEN).material = new THREE.MeshStandardMaterial({
map: topScreenTexture,
emissive: new THREE.Color(0x222222),
emissiveIntensity: 0.5,
});
requireMesh(BOTTOM_SCREEN).material = new THREE.MeshStandardMaterial({
map: bottomScreenTexture,
emissive: new THREE.Color(0x222222),
emissiveIntensity: 0.5,
});
});
const { onRender } = useLoop(); const { onRender } = useLoop();
const physicalButtonsDown = new Set<string>(); const physicalButtonsDown = new Set<string>();
@@ -192,8 +178,6 @@ const pressButton = (button: string) => {
}; };
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
if (!scene.value) return;
const domElement = renderer.instance.domElement; const domElement = renderer.instance.domElement;
const rect = domElement.getBoundingClientRect(); const rect = domElement.getBoundingClientRect();
@@ -206,7 +190,7 @@ const handleClick = (event: MouseEvent) => {
camera.activeCamera.value, camera.activeCamera.value,
); );
const intersects = raycaster.intersectObjects(scene.value.children, true); const intersects = raycaster.intersectObjects(model.children, true);
const intersection = intersects[0]; const intersection = intersects[0];
if (!intersection?.uv) return; if (!intersection?.uv) return;
@@ -311,5 +295,5 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<primitive v-if="scene" :object="scene" /> <primitive :object="model" />
</template> </template>

View File

@@ -1,4 +1,8 @@
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
const imageCache = new Map<string, HTMLImageElement>(); const imageCache = new Map<string, HTMLImageElement>();
const modelCache = new Map<string, THREE.Group>();
const loaded = ref(0); const loaded = ref(0);
const total = ref({{TOTAL}}); const total = ref({{TOTAL}});
@@ -22,6 +26,34 @@ const createImage = (path: string) => {
return img; return img;
}; };
const createModel = (path: string) => {
const cached = modelCache.get(path);
if (cached) {
return cached;
}
const model = new THREE.Group();
modelCache.set(path, model);
new GLTFLoader().load(
path,
(gltf) => {
for (const child of [...gltf.scene.children]) {
model.add(child);
}
loaded.value += 1;
},
undefined,
(error) => {
console.error(`Error loading model ${path}:`, error);
loaded.value += 1;
}
);
return model;
};
const assets = {{ASSETS}}; const assets = {{ASSETS}};
export const useAssets = () => { export const useAssets = () => {

View File

@@ -3,6 +3,8 @@ import type { Screen as NDSScreen } from "#components";
type ScreenInstance = InstanceType<typeof NDSScreen>; type ScreenInstance = InstanceType<typeof NDSScreen>;
const { isReady } = useAssets();
const route = useRoute(); const route = useRoute();
const screen = computed(() => route.query.screen as string | undefined); const screen = computed(() => route.query.screen as string | undefined);
@@ -14,7 +16,8 @@ const bottomScreenCanvas = computed(() => bottomScreen.value?.canvas ?? null);
</script> </script>
<template> <template>
<div> <LoadingScreen v-if="!isReady" />
<div v-else>
<TresCanvas window-size clear-color="#181818"> <TresCanvas window-size clear-color="#181818">
<TresPerspectiveCamera <TresPerspectiveCamera
:args="[45, 1, 0.001, 1000]" :args="[45, 1, 0.001, 1000]"

View File

@@ -1,8 +0,0 @@
<script setup lang="ts">
const { isReady } = useAssets();
</script>
<template>
<LoadingScreen v-if="!isReady" />
<p v-else>ok</p>
</template>

View File

@@ -7,17 +7,19 @@ type AssetsTree = {
[key: string]: string | AssetsTree; [key: string]: string | AssetsTree;
}; };
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]); const IMAGE_EXTENSIONS = [".png", ".webp"];
const MODELS_EXTENSIONS = [".gltf"];
const ASSETS_EXTENSIONS = [...IMAGE_EXTENSIONS, ...MODELS_EXTENSIONS];
export default defineNuxtModule({ export default defineNuxtModule({
meta: { meta: {
name: "image-assets", name: "asset-generator",
configKey: "imageAssets", configKey: "assetGenerator",
}, },
defaults: {}, defaults: {},
async setup(_, nuxt) { async setup(_, nuxt) {
const logger = useLogger("image-assets"); const logger = useLogger("asset-generator");
const publicImagesDir = join(nuxt.options.rootDir, "public/images"); const publicDir = join(nuxt.options.rootDir, "public");
const templateFile = join( const templateFile = join(
nuxt.options.rootDir, nuxt.options.rootDir,
"app/composables/useAssets.ts.in", "app/composables/useAssets.ts.in",
@@ -27,52 +29,62 @@ export default defineNuxtModule({
"app/composables/useAssets.ts", "app/composables/useAssets.ts",
); );
const isImageFile = (filename: string): boolean => { const hasExt = (filename: string, exts: string[]): boolean => {
const ext = parse(filename).ext.toLowerCase(); const ext = parse(filename).ext.toLowerCase();
return IMAGE_EXTENSIONS.has(ext); return exts.includes(ext);
}; };
const scanDirectory = async (dir: string): Promise<string[]> => { const scanDirectory = async (dir: string): Promise<string[]> => {
const images: string[] = []; const assets: string[] = [];
const entries = await readdir(dir, { withFileTypes: true }); const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
const fullPath = join(dir, entry.name); const fullPath = join(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
images.push(...(await scanDirectory(fullPath))); const assetFiles = await scanDirectory(fullPath);
} else if (isImageFile(entry.name)) { assets.push(...assetFiles);
images.push(fullPath); } else if (hasExt(fullPath, ASSETS_EXTENSIONS)) {
assets.push(fullPath);
} }
} }
return images; return assets;
}; };
const toCamelCase = (str: string): string => { const toCamelCase = (str: string): string => {
return str.replace(/[-_](.)/g, (_, c) => c.toUpperCase()); return str
.replace(/[-_](.)/g, (_, c) => c.toUpperCase())
.replaceAll(".", "_");
}; };
const buildAssetsTree = (images: string[], baseDir: string): AssetsTree => { const buildAssetsTree = (assets: string[], baseDir: string): AssetsTree => {
const tree: AssetsTree = {}; const tree: AssetsTree = {};
for (const imagePath of images) { for (const assetPath of assets) {
const relativePath = relative(baseDir, imagePath); const relativePath = relative(baseDir, assetPath);
const parts = relativePath.split("/"); const parts = relativePath.split("/");
const filename = parse(parts[parts.length - 1]!).name; const filename = parse(parts[parts.length - 1]!).name;
let current = tree; let current = tree;
for (let i = 0; i < parts.length - 1; i += 1) { // start at 1 to skip images/, models/
for (let i = 1; i < parts.length - 1; i += 1) {
const key = toCamelCase(parts[i]!); const key = toCamelCase(parts[i]!);
current[key] ??= {}; current[key] ??= {};
current = current[key] as AssetsTree; current = current[key] as AssetsTree;
} }
current[toCamelCase(filename)] = `/images/${relativePath}`; current[toCamelCase(filename)] = `/${relativePath}`;
} }
return tree; return tree;
}; };
const generatorFn = (filePath: string): string => {
if (hasExt(filePath, IMAGE_EXTENSIONS)) return "createImage";
if (hasExt(filePath, MODELS_EXTENSIONS)) return "createModel";
throw new Error(`No matching generator for '${filePath}'`);
};
const generateAssetsObject = (tree: AssetsTree, indent = 0): string => { const generateAssetsObject = (tree: AssetsTree, indent = 0): string => {
const spaces = " ".repeat(indent); const spaces = " ".repeat(indent);
const entries = Object.entries(tree); const entries = Object.entries(tree);
@@ -80,7 +92,7 @@ export default defineNuxtModule({
const lines = entries.map(([key, value]) => const lines = entries.map(([key, value]) =>
typeof value === "string" typeof value === "string"
? `${spaces} ${key}: createImage("${value}"),` ? `${spaces} ${key}: ${generatorFn(value)}("${value}"),`
: `${spaces} ${key}: ${generateAssetsObject(value, indent + 1)},`, : `${spaces} ${key}: ${generateAssetsObject(value, indent + 1)},`,
); );
@@ -89,22 +101,22 @@ export default defineNuxtModule({
const generateAssetsFile = async () => { const generateAssetsFile = async () => {
try { try {
if (!existsSync(publicImagesDir)) { if (!existsSync(publicDir)) {
logger.warn("No public/images directory found"); logger.warn("No public directory found");
return; return;
} }
const images = await scanDirectory(publicImagesDir); const assets = await scanDirectory(publicDir);
const assetsTree = buildAssetsTree(images, publicImagesDir); const assetsTree = buildAssetsTree(assets, publicDir);
const assetsObject = generateAssetsObject(assetsTree); const assetsObject = generateAssetsObject(assetsTree);
const template = await readFile(templateFile, "utf-8"); const template = await readFile(templateFile, "utf-8");
const fileContent = template const fileContent = template
.replace("{{TOTAL}}", images.length.toString()) .replace("{{TOTAL}}", assets.length.toString())
.replace("{{ASSETS}}", assetsObject); .replace("{{ASSETS}}", assetsObject);
await writeFile(outputFile, fileContent, "utf-8"); await writeFile(outputFile, fileContent, "utf-8");
logger.success(`Generated useAssets.ts with ${images.length} images`); logger.success(`Generated useAssets.ts with ${assets.length} assets`);
} catch (error) { } catch (error) {
logger.error("Error generating assets file:", error); logger.error("Error generating assets file:", error);
} }
@@ -116,12 +128,17 @@ export default defineNuxtModule({
if (nuxt.options.dev) { if (nuxt.options.dev) {
nuxt.hook("ready", () => { nuxt.hook("ready", () => {
watch(publicImagesDir, { recursive: true }, async (_, filePath) => { watch(publicDir, { recursive: true }, async (_, filePath) => {
if (filePath && isImageFile(filePath)) { if (filePath && hasExt(filePath, ASSETS_EXTENSIONS)) {
logger.info(`Detected change: ${filePath}`); logger.info(`Detected change: ${filePath}`);
await generateAssetsFile(); await generateAssetsFile();
} }
}); });
watch(templateFile, async () => {
logger.info("Template file changed");
await generateAssetsFile();
});
}); });
} }
}, },

View File

@@ -7,7 +7,7 @@ export default defineNuxtConfig({
"@nuxt/content", "@nuxt/content",
"@pinia/nuxt", "@pinia/nuxt",
"./modules/content-assets", "./modules/content-assets",
"./modules/image-assets", "./modules/asset-generator",
"@nuxtjs/i18n", "@nuxtjs/i18n",
"@tresjs/nuxt", "@tresjs/nuxt",
], ],