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">
|
<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>
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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]"
|
||||||
|
|||||||
@@ -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;
|
[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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -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",
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user