feat(assets): load 3d models as well
This commit is contained in:
7
app/components/LoadingScreen.vue
Normal file
7
app/components/LoadingScreen.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
const { loaded, total } = useAssets();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p>{{ loaded }} / {{ total }}</p>
|
||||
</template>
|
||||
@@ -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,27 +66,6 @@ watch(
|
||||
bottomScreenTexture.flipY = false;
|
||||
bottomScreenTexture.repeat.set(1, 1024 / 532);
|
||||
bottomScreenTexture.offset.set(0, -1024 / 532 + 1);
|
||||
},
|
||||
{ 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,
|
||||
@@ -94,7 +78,9 @@ watch(scene, () => {
|
||||
emissive: new THREE.Color(0x222222),
|
||||
emissiveIntensity: 0.5,
|
||||
});
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const { onRender } = useLoop();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const { isReady } = useAssets();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadingScreen v-if="!isReady" />
|
||||
<p v-else>ok</p>
|
||||
</template>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -7,7 +7,7 @@ export default defineNuxtConfig({
|
||||
"@nuxt/content",
|
||||
"@pinia/nuxt",
|
||||
"./modules/content-assets",
|
||||
"./modules/image-assets",
|
||||
"./modules/asset-generator",
|
||||
"@nuxtjs/i18n",
|
||||
"@tresjs/nuxt",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user