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">
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { useLoop, useTresContext } from "@tresjs/core";
import * as THREE from "three";
@@ -8,12 +7,9 @@ const props = defineProps<{
bottomScreenCanvas: HTMLCanvasElement | null;
}>();
const { state: model } = useLoader(
GLTFLoader,
"/models/nintendo-ds/scene.gltf",
);
const { assets } = useAssets();
const scene = computed(() => model.value?.scene);
const model = assets.nintendoDs.scene.clone(true);
let topScreenTexture: THREE.CanvasTexture | null = null;
let bottomScreenTexture: THREE.CanvasTexture | null = null;
@@ -43,6 +39,15 @@ const requireMesh = (key: string): THREE.Mesh => {
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(
() => [props.topScreenCanvas, props.bottomScreenCanvas],
() => {
@@ -61,41 +66,22 @@ watch(
bottomScreenTexture.flipY = false;
bottomScreenTexture.repeat.set(1, 1024 / 532);
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 },
);
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 physicalButtonsDown = new Set<string>();
@@ -192,8 +178,6 @@ const pressButton = (button: string) => {
};
const handleClick = (event: MouseEvent) => {
if (!scene.value) return;
const domElement = renderer.instance.domElement;
const rect = domElement.getBoundingClientRect();
@@ -206,7 +190,7 @@ const handleClick = (event: MouseEvent) => {
camera.activeCamera.value,
);
const intersects = raycaster.intersectObjects(scene.value.children, true);
const intersects = raycaster.intersectObjects(model.children, true);
const intersection = intersects[0];
if (!intersection?.uv) return;
@@ -311,5 +295,5 @@ onUnmounted(() => {
</script>
<template>
<primitive v-if="scene" :object="scene" />
<primitive :object="model" />
</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 modelCache = new Map<string, THREE.Group>();
const loaded = ref(0);
const total = ref({{TOTAL}});
@@ -22,6 +26,34 @@ const createImage = (path: string) => {
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}};
export const useAssets = () => {

View File

@@ -3,6 +3,8 @@ import type { Screen as NDSScreen } from "#components";
type ScreenInstance = InstanceType<typeof NDSScreen>;
const { isReady } = useAssets();
const route = useRoute();
const screen = computed(() => route.query.screen as string | undefined);
@@ -14,7 +16,8 @@ const bottomScreenCanvas = computed(() => bottomScreen.value?.canvas ?? null);
</script>
<template>
<div>
<LoadingScreen v-if="!isReady" />
<div v-else>
<TresCanvas window-size clear-color="#181818">
<TresPerspectiveCamera
: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;
};
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({
meta: {
name: "image-assets",
configKey: "imageAssets",
name: "asset-generator",
configKey: "assetGenerator",
},
defaults: {},
async setup(_, nuxt) {
const logger = useLogger("image-assets");
const publicImagesDir = join(nuxt.options.rootDir, "public/images");
const logger = useLogger("asset-generator");
const publicDir = join(nuxt.options.rootDir, "public");
const templateFile = join(
nuxt.options.rootDir,
"app/composables/useAssets.ts.in",
@@ -27,52 +29,62 @@ export default defineNuxtModule({
"app/composables/useAssets.ts",
);
const isImageFile = (filename: string): boolean => {
const hasExt = (filename: string, exts: string[]): boolean => {
const ext = parse(filename).ext.toLowerCase();
return IMAGE_EXTENSIONS.has(ext);
return exts.includes(ext);
};
const scanDirectory = async (dir: string): Promise<string[]> => {
const images: string[] = [];
const assets: string[] = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
images.push(...(await scanDirectory(fullPath)));
} else if (isImageFile(entry.name)) {
images.push(fullPath);
const assetFiles = await scanDirectory(fullPath);
assets.push(...assetFiles);
} else if (hasExt(fullPath, ASSETS_EXTENSIONS)) {
assets.push(fullPath);
}
}
return images;
return assets;
};
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 = {};
for (const imagePath of images) {
const relativePath = relative(baseDir, imagePath);
for (const assetPath of assets) {
const relativePath = relative(baseDir, assetPath);
const parts = relativePath.split("/");
const filename = parse(parts[parts.length - 1]!).name;
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]!);
current[key] ??= {};
current = current[key] as AssetsTree;
}
current[toCamelCase(filename)] = `/images/${relativePath}`;
current[toCamelCase(filename)] = `/${relativePath}`;
}
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 spaces = " ".repeat(indent);
const entries = Object.entries(tree);
@@ -80,7 +92,7 @@ export default defineNuxtModule({
const lines = entries.map(([key, value]) =>
typeof value === "string"
? `${spaces} ${key}: createImage("${value}"),`
? `${spaces} ${key}: ${generatorFn(value)}("${value}"),`
: `${spaces} ${key}: ${generateAssetsObject(value, indent + 1)},`,
);
@@ -89,22 +101,22 @@ export default defineNuxtModule({
const generateAssetsFile = async () => {
try {
if (!existsSync(publicImagesDir)) {
logger.warn("No public/images directory found");
if (!existsSync(publicDir)) {
logger.warn("No public directory found");
return;
}
const images = await scanDirectory(publicImagesDir);
const assetsTree = buildAssetsTree(images, publicImagesDir);
const assets = await scanDirectory(publicDir);
const assetsTree = buildAssetsTree(assets, publicDir);
const assetsObject = generateAssetsObject(assetsTree);
const template = await readFile(templateFile, "utf-8");
const fileContent = template
.replace("{{TOTAL}}", images.length.toString())
.replace("{{TOTAL}}", assets.length.toString())
.replace("{{ASSETS}}", assetsObject);
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) {
logger.error("Error generating assets file:", error);
}
@@ -116,12 +128,17 @@ export default defineNuxtModule({
if (nuxt.options.dev) {
nuxt.hook("ready", () => {
watch(publicImagesDir, { recursive: true }, async (_, filePath) => {
if (filePath && isImageFile(filePath)) {
watch(publicDir, { recursive: true }, async (_, filePath) => {
if (filePath && hasExt(filePath, ASSETS_EXTENSIONS)) {
logger.info(`Detected change: ${filePath}`);
await generateAssetsFile();
}
});
watch(templateFile, async () => {
logger.info("Template file changed");
await generateAssetsFile();
});
});
}
},

View File

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