Explorar el Código

feat(useTexture)!: refactor to be a real composable with a state

BREAKING CHANGE: useTexture no longer returns the plain texture, it now returns and object with reactive data (texture|s), isLoading, error) and a load method, can be used both sync and async (suspense)

- Completely rewrite `useTexture` composable with more robust loading mechanism
- Add comprehensive type support for single and multiple texture loading
- Implement reactive loading states (isLoading, error)
- Update component and tests to match new composable implementation
- Remove deprecated texture loading patterns
- Enhance error handling and async loading behavior
alvarosabu hace 6 meses
padre
commit
a7904ebf57

+ 13 - 1
.vscode/launch.json

@@ -23,6 +23,18 @@
       "cwd": "${workspaceRoot}/packages/tres",
       "runtimeExecutable": "npm",
       "runtimeArgs": ["run", "dev", "--preserve-symlinks"]
-    }
+    },
+    {
+      "type": "node",
+      "request": "launch",
+      "name": "Debug Vitest Tests",
+      "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
+      "args": ["run"],
+      "autoAttachChildProcesses": true,
+      "smartStep": true,
+      "console": "integratedTerminal",
+      "internalConsoleOptions": "neverOpen",
+      "skipFiles": ["<node_internals>/**"]
+    },
   ]
 }

+ 0 - 23
playground/vue/src/pages/basic/Textures.vue

@@ -1,23 +0,0 @@
-<script setup>
-import { OrbitControls } from '@tresjs/cientos'
-import { TresCanvas, UseTexture } from '@tresjs/core'
-
-const path = 'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Displacement.jpg'
-</script>
-
-<template>
-  <TresCanvas window-size clear-color="#111">
-    <TresPerspectiveCamera :position="[0, 0, 3]" :fov="45" :aspect="1" :near="0.1" :far="1000" />
-    <OrbitControls />
-    <Suspense>
-      <UseTexture v-slot="{ textures }" :map="path" :displacement-map="path">
-        <TresMesh>
-          <TresBoxGeometry :args="[1, 1, 1, 50, 50, 50]" />
-          <TresMeshStandardMaterial :map="textures.map" :displacement-map="textures.displacementMap" :displacement-scale="0.1" />
-        </TresMesh>
-      </UseTexture>
-    </Suspense>
-    <TresDirectionalLight :position="[4, 4, 4]" />
-    <TresAmbientLight :intensity="0.5" />
-  </TresCanvas>
-</template>

+ 28 - 0
playground/vue/src/pages/composables/useTexture/ObjectASyncMultipleTexture.vue

@@ -0,0 +1,28 @@
+<script setup lang="ts">
+/* eslint-disable no-console */
+import { Html } from '@tresjs/cientos'
+import { useTexture } from '@tresjs/core'
+
+const { data: texture } = await useTexture([
+  'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Color.jpg',
+  'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Displacement.jpg',
+])
+
+watch(texture, (newVal) => {
+  console.log('Multiple texture Async', newVal)
+}, { immediate: true })
+
+// eslint-enable no-console
+</script>
+
+<template>
+  <TresMesh :position="[9, 1, 0]">
+    <Html transform position-y="1.5">
+      <span class="text-xs bg-white p-2 rounded-md">
+        Multiple (async)
+      </span>
+    </Html>
+    <TresSphereGeometry :args="[1, 32, 32]" />
+    <TresMeshStandardMaterial v-if="texture" :map="texture[0]" :displacement-map="texture[1]" :displacement-scale="0.1" />
+  </TresMesh>
+</template>

+ 25 - 0
playground/vue/src/pages/composables/useTexture/ObjectAsyncSimpleTexture.vue

@@ -0,0 +1,25 @@
+<script setup lang="ts">
+/* eslint-disable no-console */
+import { Html } from '@tresjs/cientos'
+import { useTexture } from '@tresjs/core'
+
+const { data: texture } = await useTexture('https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Color.jpg')
+
+watch(texture, (newVal) => {
+  console.log('Simple texture async', newVal)
+}, { immediate: true })
+
+// eslint-enable no-console
+</script>
+
+<template>
+  <TresMesh :position="[6, 1, 0]">
+    <Html transform position-y="1.5">
+      <span class="text-xs bg-white p-2 rounded-md">
+        Simple (async)
+      </span>
+    </Html>
+    <TresSphereGeometry :args="[1, 32, 32]" />
+    <TresMeshStandardMaterial :map="texture" />
+  </TresMesh>
+</template>

+ 35 - 0
playground/vue/src/pages/composables/useTexture/ObjectSyncLoadMultipleTexture.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts">
+/* eslint-disable no-console */
+import { Html } from '@tresjs/cientos'
+import { useTexture } from '@tresjs/core'
+
+const { data: texture, load } = useTexture([
+  'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Color.jpg',
+  'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Displacement.jpg',
+])
+
+watch(texture, (newVal) => {
+  console.log('Load mutiple', newVal)
+}, { immediate: true })
+
+setTimeout(() => {
+  load([
+    'https://raw.githubusercontent.com/Tresjs/assets/main/textures/hexagonal-rock/Rocks_Hexagons_002_basecolor.jpg',
+    'https://raw.githubusercontent.com/Tresjs/assets/main/textures/hexagonal-rock/Rocks_Hexagons_002_normal.jpg',
+  ])
+}, 2000)
+
+// eslint-enable no-console
+</script>
+
+<template>
+  <TresMesh :position="[-9, 1, 0]">
+    <Html transform position-y="1.5">
+      <span class="text-xs bg-white p-2 rounded-md">
+        Load multiple
+      </span>
+    </Html>
+    <TresSphereGeometry :args="[1, 32, 32]" />
+    <TresMeshStandardMaterial :map="texture[0]" :displacement-map="texture[1]" :displacement-scale="0.1" />
+  </TresMesh>
+</template>

+ 29 - 0
playground/vue/src/pages/composables/useTexture/ObjectSyncLoadSimpleTexture.vue

@@ -0,0 +1,29 @@
+<script setup lang="ts">
+/* eslint-disable no-console */
+import { Html } from '@tresjs/cientos'
+import { useTexture } from '@tresjs/core'
+
+const { data: texture, load } = useTexture('https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Color.jpg')
+
+watch(texture, (newVal) => {
+  console.log('Load new texture', newVal)
+}, { immediate: true })
+
+setTimeout(() => {
+  load('https://raw.githubusercontent.com/Tresjs/assets/main/textures/hexagonal-rock/Rocks_Hexagons_002_basecolor.jpg')
+}, 2000)
+
+// eslint-enable no-console
+</script>
+
+<template>
+  <TresMesh :position="[-6, 1, 0]">
+    <Html transform position-y="1.5">
+      <span class="text-xs bg-white p-2 rounded-md">
+        Load
+      </span>
+    </Html>
+    <TresSphereGeometry :args="[1, 32, 32]" />
+    <TresMeshStandardMaterial :map="texture" />
+  </TresMesh>
+</template>

+ 36 - 0
playground/vue/src/pages/composables/useTexture/ObjectSyncMultipleTexture.vue

@@ -0,0 +1,36 @@
+<script setup lang="ts">
+/* eslint-disable no-console */
+import { Html } from '@tresjs/cientos'
+import { useTexture } from '@tresjs/core'
+
+const { data: texture, isLoading, error } = useTexture([
+  'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Color.jpg',
+  'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Displacement.jpg',
+])
+
+watch(texture, (newVal) => {
+  console.log('Multiple texture sync', newVal)
+}, { immediate: true })
+
+watch(isLoading, (newVal) => {
+  console.log('Multiple texture isLoading', newVal)
+}, { immediate: true })
+
+watch(error, (newVal) => {
+  console.log('Multiple texture error', newVal)
+}, { immediate: true })
+
+// eslint-enable no-console
+</script>
+
+<template>
+  <TresMesh :position="[3, 1, 0]">
+    <Html transform position-y="1.5">
+      <span class="text-xs bg-white p-2 rounded-md">
+        Multiple (sync) {{ isLoading ? 'loading...' : 'loaded' }}
+      </span>
+    </Html>
+    <TresSphereGeometry :args="[1, 32, 32]" />
+    <TresMeshStandardMaterial v-if="texture" :map="texture[0]" :displacement-map="texture[1]" :displacement-scale="0.1" />
+  </TresMesh>
+</template>

+ 29 - 0
playground/vue/src/pages/composables/useTexture/ObjectSyncSimpleTexture.vue

@@ -0,0 +1,29 @@
+<script setup lang="ts">
+/* eslint-disable no-console */
+import { Html } from '@tresjs/cientos'
+import { useTexture } from '@tresjs/core'
+
+const { data: texture, isLoading, error } = useTexture('https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Color.jpg')
+
+watch(texture, (newVal) => {
+  console.log('Simple texture sync', newVal)
+}, { immediate: true })
+
+watch(isLoading, (newVal) => {
+  console.log('Simple texture sync loading', newVal)
+}, { immediate: true })
+
+// eslint-enable no-console
+</script>
+
+<template>
+  <TresMesh position-y="1">
+    <Html transform position-y="1.5">
+      <span class="text-xs bg-white p-2 rounded-md">
+        Simple (sync) {{ isLoading ? 'Loading...' : 'Loaded' }}
+      </span>
+    </Html>
+    <TresSphereGeometry :args="[1, 32, 32]" />
+    <TresMeshStandardMaterial :map="texture" />
+  </TresMesh>
+</template>

+ 23 - 0
playground/vue/src/pages/composables/useTexture/ObjectUseTextureComponent.vue

@@ -0,0 +1,23 @@
+<script setup lang="ts">
+import { UseTexture } from '@tresjs/core'
+import { Html } from '@tresjs/cientos'
+
+const paths = [
+  'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Color.jpg',
+  'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Displacement.jpg',
+]
+</script>
+
+<template>
+  <UseTexture v-slot="{ data: texture }" :path="paths">
+    <TresMesh :position="[-3, 1, 0]">
+      <Html transform position-y="1.5">
+        <span class="text-xs bg-white p-2 rounded-md">
+          Use texture component
+        </span>
+      </Html>
+      <TresSphereGeometry :args="[1, 32, 32]" />
+      <TresMeshStandardMaterial v-if="texture" :map="texture[0]" :displacement-map="texture[1]" :displacement-scale="0.1" />
+    </TresMesh>
+  </UseTexture>
+</template>

+ 42 - 0
playground/vue/src/pages/composables/useTexture/index.vue

@@ -0,0 +1,42 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
+
+import { OrbitControls } from '@tresjs/cientos'
+import ObjectAsyncSimpleTexture from './ObjectAsyncSimpleTexture.vue'
+import ObjectSyncMultipleTexture from './ObjectSyncMultipleTexture.vue'
+import ObjectSyncSimpleTexture from './ObjectSyncSimpleTexture.vue'
+import ObjectAsyncMultipleTexture from './ObjectAsyncMultipleTexture.vue'
+import ObjectUseTextureComponent from './ObjectUseTextureComponent.vue'
+import ObjectSyncLoadSimpleTexture from './ObjectSyncLoadSimpleTexture.vue'
+import ObjectSyncLoadMultipleTexture from './ObjectSyncLoadMultipleTexture.vue'
+
+const gl = {
+  clearColor: '#82DBC5',
+  shadows: true,
+  alpha: false,
+  shadowMapType: BasicShadowMap,
+  outputColorSpace: SRGBColorSpace,
+  toneMapping: NoToneMapping,
+}
+</script>
+
+<template>
+  <TresCanvas v-bind="gl">
+    <TresPerspectiveCamera :position="[8, 8, 8]" />
+    <OrbitControls />
+    <TresGridHelper :args="[100, 100]" />
+    <TresAmbientLight :intensity="1" />
+    <ObjectSyncSimpleTexture />
+    <ObjectSyncMultipleTexture />
+    <Suspense>
+      <ObjectAsyncSimpleTexture />
+    </Suspense>
+    <Suspense>
+      <ObjectAsyncMultipleTexture />
+    </Suspense>
+    <ObjectUseTextureComponent />
+    <ObjectSyncLoadSimpleTexture />
+    <ObjectSyncLoadMultipleTexture />
+  </TresCanvas>
+</template>

+ 2 - 0
playground/vue/src/pages/index.vue

@@ -3,6 +3,7 @@ import {
   advancedRoutes,
   basicRoutes,
   cameraRoutes,
+  composablesRoutes,
   eventsRoutes,
   issuesRoutes,
   loaderRoutes,
@@ -12,6 +13,7 @@ import {
 
 const sections = [
   { icon: '📦', title: 'Basic', routes: basicRoutes },
+  { icon: '🎼', title: 'Composables', routes: composablesRoutes },
   { icon: '🤓', title: 'Advanced', routes: advancedRoutes },
   { icon: '📣', title: 'Events', routes: eventsRoutes },
   { icon: '📷', title: 'Camera', routes: cameraRoutes },

+ 0 - 5
playground/vue/src/router/routes/basic.ts

@@ -49,9 +49,4 @@ export const basicRoutes = [
     name: '@ready',
     component: () => import('../../pages/basic/ready/index.vue'),
   },
-  {
-    path: '/basic/textures',
-    name: 'Textures',
-    component: () => import('../../pages/basic/Textures.vue'),
-  },
 ]

+ 7 - 0
playground/vue/src/router/routes/composables.ts

@@ -0,0 +1,7 @@
+export const composablesRoutes = [
+  {
+    name: 'useTexture',
+    path: '/composables/use-texture',
+    component: () => import('../../pages/composables/useTexture/index.vue'),
+  },
+]

+ 3 - 0
playground/vue/src/router/routes/index.ts

@@ -6,6 +6,7 @@ import { issuesRoutes } from './issues'
 import { loaderRoutes } from './loaders'
 import { miscRoutes } from './misc'
 import { modelsRoutes } from './models'
+import { composablesRoutes } from './composables'
 
 const allRoutes = [
   ...basicRoutes,
@@ -16,6 +17,7 @@ const allRoutes = [
   ...miscRoutes,
   ...issuesRoutes,
   ...loaderRoutes,
+  ...composablesRoutes,
 ]
 
 export {
@@ -23,6 +25,7 @@ export {
   allRoutes,
   basicRoutes,
   cameraRoutes,
+  composablesRoutes,
   eventsRoutes,
   issuesRoutes,
   loaderRoutes,

+ 34 - 5
src/composables/useTexture/component.vue

@@ -1,13 +1,42 @@
 <script setup lang="ts">
-import type { PBRUseTextureMap } from './index'
 import { reactive } from 'vue'
-import { useTexture } from './index'
+import type { LoadingManager, Texture } from 'three'
+import { useTexture, type UseTextureReturn } from './index'
 
-const props = defineProps<PBRUseTextureMap>()
+interface Props {
+  /**
+   * Path or array of paths to texture(s)
+   */
+  path: string | string[]
+  /**
+   * Optional THREE.js LoadingManager
+   */
+  manager?: LoadingManager
+}
 
-const data = await reactive(useTexture(props))
+interface Emits {
+  (e: 'loaded'): void
+  (e: 'error', error: Error): void
+}
+
+const props = defineProps<Props>()
+const emit = defineEmits<Emits>()
+
+// Type guard to handle the union type
+const textureData = Array.isArray(props.path)
+  ? reactive<UseTextureReturn<Texture[]>>(useTexture(props.path, props.manager))
+  : reactive<UseTextureReturn<Texture>>(useTexture(props.path, props.manager))
+
+// Handle loading state
+textureData.promise
+  .then(() => emit('loaded'))
+  .catch(err => emit('error', err))
 </script>
 
 <template>
-  <slot :textures="data"></slot>
+  <slot
+    :data="textureData.data"
+    :is-loading="textureData.isLoading"
+    :error="textureData.error"
+  ></slot>
 </template>

+ 109 - 131
src/composables/useTexture/index.ts

@@ -1,161 +1,139 @@
+import { ref, type Ref, shallowRef } from 'vue'
 import type { LoadingManager, Texture } from 'three'
 import { TextureLoader } from 'three'
-import { isArray } from '../../utils'
 
-export interface PBRMaterialOptions {
+export interface UseTextureReturn<T> {
   /**
-   * List of texture maps to load.
-   *
-   * @type {string[]}
-   * @memberof PBRMaterialOptions
+   * The loaded texture(s)
    */
-  maps: string[]
+  data: Ref<T | null>
   /**
-   * Path to the texture maps.
-   *
-   * @type {('png' | 'jpg')}
-   * @memberof PBRMaterialOptions
+   * Whether the texture is currently loading
    */
-  ext: 'png' | 'jpg'
-}
-
-export interface PBRTextureMaps {
-  [key: string]: Texture | null
-}
-
-/**
- * Map of textures to load that can be passed to `useTexture()`.
- */
-export interface PBRUseTextureMap {
-  map?: string
-  displacementMap?: string
-  normalMap?: string
-  roughnessMap?: string
-  metalnessMap?: string
-  aoMap?: string
-  alphaMap?: string
-  matcap?: string
+  isLoading: Ref<boolean>
+  /**
+   * Any error that occurred during loading
+   */
+  error: Ref<Error | null>
+  /**
+   * Promise that resolves when the texture is loaded
+   */
+  promise: Promise<T>
+  /**
+   * Load one or more textures
+   */
+  load: {
+    (url: string): Promise<Texture>
+    (urls: string[]): Promise<Texture[]>
+  }
 }
 
 /**
- * Loads a single texture.
+ * Vue composable for loading textures with THREE.js
+ * Can be used with or without await/Suspense
  *
+ * @example
  * ```ts
- * import { useTexture } from 'tres'
+ * import { useTexture } from '@tresjs/core'
  *
- * const matcapTexture = await useTexture(['path/to/texture.png'])
- * ```
- * Then you can use the texture in your material.
+ * // Single texture
+ * const { data: texture } = useTexture('path/to/texture.png')
  *
- * ```vue
- * <TresMeshMatcapMaterial :matcap="matcapTexture" />
- * ```
- * @see https://tresjs.org/examples/load-textures.html
- * @export
- * @param paths
- * @return A promise of the resulting texture
- */
-export async function useTexture(paths: readonly [string]): Promise<Texture>
-/**
- * Loads multiple textures.
- *
- * ```ts
- * import { useTexture } from 'tres'
- *
- * const [texture1, texture2] = await useTexture([
- *  'path/to/texture1.png',
- *  'path/to/texture2.png',
+ * // Multiple textures - returns array of textures
+ * const { data: textures } = useTexture([
+ *   'path/to/albedo.png',
+ *   'path/to/displacement.png'
  * ])
- * ```
- * Then you can use the texture in your material.
+ * // Access individual textures
+ * const [albedo, displacement] = textures.value
  *
- * ```vue
- * <TresMeshStandardMaterial map="texture1" />
+ * // With async/await
+ * const { data } = await useTexture('texture.png')
  * ```
- * @see https://tresjs.org/examples/load-textures.html
- * @export
- * @param paths
- * @return A promise of the resulting textures
- */
-export async function useTexture<T extends string[]>(
-  paths: [...T]
-): Promise<{ [K in keyof T]: Texture }>
-/**
- * Loads a PBR texture map.
  *
- * ```ts
- * import { useTexture } from 'tres'
- *
- * const pbrTexture = await useTexture({
- *  map: 'path/to/texture.png',
- *  displacementMap: 'path/to/displacement-map.png',
- *  roughnessMap: 'path/to/roughness-map.png',
- *  normalMap: 'path/to/normal-map.png',
- *  ambientOcclusionMap: 'path/to/ambient-occlusion-map.png',
- * })
- * ```
- * Then you can use the texture in your material.
- *
- * ```vue
- * <TresMeshStandardMaterial v-bind="pbrTexture" />
- * ```
- * @see https://tresjs.org/examples/load-textures.html
- * @export
- * @param paths
- * @return A promise of the resulting pbr texture map
+ * @param path - Path or paths to texture(s)
+ * @param manager - Optional THREE.js LoadingManager
  */
-export async function useTexture<TextureMap extends PBRUseTextureMap>(
-  paths: TextureMap
-): Promise<{
-  [K in keyof Required<PBRUseTextureMap>]: K extends keyof TextureMap
-    ? Texture
-    : null
-}>
-
-export async function useTexture(
-  paths: readonly [string] | string[] | PBRUseTextureMap,
+export function useTexture(path: string, manager?: LoadingManager): UseTextureReturn<Texture>
+export function useTexture(paths: string[], manager?: LoadingManager): UseTextureReturn<Texture[]>
+export function useTexture(
+  paths: string | string[],
   manager?: LoadingManager,
-): Promise<Texture | Texture[] | PBRTextureMaps> {
+): UseTextureReturn<Texture | Texture[]> {
+  const data = shallowRef<Texture | Texture[] | null>(null)
+  const isLoading = ref(true)
+  const error = ref<Error | null>(null)
+
   const textureLoader = new TextureLoader(manager)
 
-  /**
-   * Load a texture.
-   *
-   * @param {string} url
-   * @return {*}  {Promise<Texture>}
-   */
-  const loadTexture = (url: string): Promise<Texture> => new Promise((resolve, reject) => {
-    textureLoader.load(
-      url,
-      texture => resolve(texture),
-      () => null,
-      () => {
-        reject(new Error('[useTextures] - Failed to load texture'))
-      },
-    )
-  })
+  const loadTexture = (url: string): Promise<Texture> =>
+    new Promise((resolve, reject) => {
+      try {
+        // Create texture synchronously and handle async loading
+        const texture = textureLoader.load(
+          url,
+          (loadedTexture) => {
+            isLoading.value = false
+            resolve(loadedTexture)
+          },
+          undefined,
+          (err) => {
+            error.value = new Error(`Failed to load texture: ${err instanceof Error ? err.message : 'Unknown error'}`)
+            isLoading.value = false
+            reject(error.value)
+          },
+        )
+        return texture
+      }
+      catch (err) {
+        error.value = err as Error
+        isLoading.value = false
+        reject(error.value)
+        return null
+      }
+    })
+
+  // Overloaded load function
+  const load = ((paths: string | string[]): Promise<Texture | Texture[]> => {
+    isLoading.value = true
+    error.value = null
 
-  if (isArray(paths)) {
-    const textures = await Promise.all((paths as Array<string>).map(path => loadTexture(path)))
-    if ((paths as Array<string>).length > 1) {
-      return textures
+    if (typeof paths === 'string') {
+      const texture = textureLoader.load(paths)
+      data.value = texture
+      return loadTexture(paths)
     }
     else {
-      return textures[0]
+      // Create textures synchronously first
+      const textures = paths.map(path => textureLoader.load(path))
+      // Set data.value immediately with synchronous textures
+      data.value = textures
+      // Handle async loading
+      return Promise.all(paths.map(path => loadTexture(path)))
     }
+  }) as UseTextureReturn<Texture | Texture[]>['load']
+
+  const returnValue = {
+    data,
+    isLoading,
+    error,
+    load,
+  } as UseTextureReturn<Texture | Texture[]>
+
+  // Initial load
+  if (typeof paths === 'string') {
+    const texture = textureLoader.load(paths)
+    data.value = texture
+    returnValue.promise = loadTexture(paths)
   }
   else {
-    const { map, displacementMap, normalMap, roughnessMap, metalnessMap, aoMap, alphaMap, matcap,
-    } = paths as { [key: string]: string }
-    return {
-      map: map ? await loadTexture(map) : null,
-      displacementMap: displacementMap ? await loadTexture(displacementMap) : null,
-      normalMap: normalMap ? await loadTexture(normalMap) : null,
-      roughnessMap: roughnessMap ? await loadTexture(roughnessMap) : null,
-      metalnessMap: metalnessMap ? await loadTexture(metalnessMap) : null,
-      aoMap: aoMap ? await loadTexture(aoMap) : null,
-      alphaMap: alphaMap ? await loadTexture(alphaMap) : null,
-      matcap: matcap ? await loadTexture(matcap) : null,
-    }
+    // Create textures synchronously first
+    const textures = paths.map(path => textureLoader.load(path))
+    // Set data.value immediately with synchronous textures
+    data.value = textures
+    // Handle async loading
+    returnValue.promise = Promise.all(paths.map(path => loadTexture(path)))
   }
+
+  return returnValue
 }

+ 141 - 11
src/composables/useTexture/useTexture.test.ts

@@ -1,19 +1,149 @@
-import { LoadingManager, TextureLoader } from 'three'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { LoadingManager, Texture, TextureLoader } from 'three'
 import { useTexture } from '.'
+import { nextTick } from 'vue'
+
+const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0))
 
 describe('useTexture', () => {
+  const mockTexture = new Texture()
+  const mockTextureUrl = 'https://example.com/texture.png'
+  const mockMultipleUrls = [
+    'https://example.com/texture1.png',
+    'https://example.com/texture2.png',
+  ]
+
+  beforeEach(() => {
+    // Mock TextureLoader.load to return sync texture and call onLoad
+    vi.spyOn(TextureLoader.prototype, 'load').mockImplementation((_, onLoad) => {
+      if (onLoad) { setTimeout(() => onLoad(mockTexture), 0) }
+      return mockTexture
+    })
+  })
+
   afterEach(() => {
+    vi.clearAllMocks()
     vi.restoreAllMocks()
   })
-  // TODO: Add tests, maybe mock the texture loader?
-  it.todo('should load a single texture', async () => {
-    const loadingManager = new LoadingManager()
-    const textureLoader = new TextureLoader(loadingManager)
-
-    const spy = vi.spyOn(textureLoader, 'load').mockImplementation(() => {})
-    await useTexture([
-      'https://github.com/Tresjs/assets/blob/main/textures/stylized-grass/stylized-grass1_albedo.png?raw=true',
-    ])
-    expect(spy).toHaveBeenCalledTimes(1)
+
+  describe('single texture', () => {
+    it('should return texture synchronously', () => {
+      const { data, isLoading, error } = useTexture(mockTextureUrl)
+
+      expect(data.value).toBe(mockTexture)
+      expect(isLoading.value).toBe(true)
+      expect(error.value).toBe(null)
+    })
+
+    it('should update loading state when texture loads', async () => {
+      const { data, isLoading, error } = useTexture(mockTextureUrl)
+
+      expect(data.value).toBe(mockTexture)
+      await flushPromises()
+      expect(isLoading.value).toBe(false)
+      expect(error.value).toBe(null)
+    })
+
+    it('should work with async/await', async () => {
+      const { data, isLoading, error } = await useTexture(mockTextureUrl)
+
+      expect(data.value).toBe(mockTexture)
+      expect(isLoading.value).toBe(true)
+      await flushPromises()
+      expect(isLoading.value).toBe(false)
+      expect(error.value).toBe(null)
+    })
+
+    it('should handle loading errors', async () => {
+      const mockError = new Error('Failed to load texture')
+      vi.spyOn(TextureLoader.prototype, 'load').mockImplementation((_, __, ___, onError) => {
+        if (onError) { onError(mockError) }
+        return mockTexture
+      })
+
+      const { isLoading, error, promise } = useTexture(mockTextureUrl)
+
+      // Catch the promise rejection to prevent unhandled rejection
+      await promise.catch(() => {
+        // Expected to reject
+      })
+
+      await flushPromises()
+
+      expect(error.value).toBeTruthy()
+      expect(error.value?.message).toContain('Failed to load texture')
+      expect(isLoading.value).toBe(false)
+    })
+
+    it('should use provided loading manager', () => {
+      const loadingManager = new LoadingManager()
+
+      // Just verify that the function works with a loading manager
+      const { data } = useTexture(mockTextureUrl, loadingManager)
+
+      expect(data.value).toBe(mockTexture)
+    })
+  })
+
+  describe('multiple textures', () => {
+    it('should return array of textures synchronously', () => {
+      const { data, isLoading, error } = useTexture(mockMultipleUrls)
+
+      expect(Array.isArray(data.value)).toBe(true)
+      expect(data.value).toHaveLength(2)
+      expect(isLoading.value).toBe(true)
+      expect(error.value).toBe(null)
+    })
+
+    it('should update loading state when all textures load', async () => {
+      const { data, isLoading, error } = useTexture(mockMultipleUrls)
+
+      expect(Array.isArray(data.value)).toBe(true)
+      expect(data.value).toHaveLength(2)
+      await flushPromises()
+      expect(isLoading.value).toBe(false)
+      expect(error.value).toBe(null)
+    })
+
+    it('should work with async/await', async () => {
+      const { data, isLoading, error } = await useTexture(mockMultipleUrls)
+
+      expect(Array.isArray(data.value)).toBe(true)
+      expect(data.value).toHaveLength(2)
+      expect(isLoading.value).toBe(true)
+      await flushPromises()
+      expect(isLoading.value).toBe(false)
+      expect(error.value).toBe(null)
+    })
+
+    it('should handle loading errors in array', async () => {
+      const mockError = new Error('Failed to load texture')
+      vi.spyOn(TextureLoader.prototype, 'load').mockImplementation((_, __, ___, onError) => {
+        if (onError) { onError(mockError) }
+        return mockTexture
+      })
+
+      const { isLoading, error, promise } = useTexture(mockMultipleUrls)
+
+      // Catch the promise rejection to prevent unhandled rejection
+      await promise.catch(() => {
+        // Expected to reject
+      })
+
+      await flushPromises()
+
+      expect(error.value).toBeTruthy()
+      expect(error.value?.message).toContain('Failed to load texture')
+      expect(isLoading.value).toBe(false)
+    })
+  })
+
+  describe('load method', () => {
+    it('should allow loading additional textures', async () => {
+      const { load } = useTexture(mockTextureUrl)
+      const additionalTexture = await load('additional-texture.png')
+
+      expect(additionalTexture).toBe(mockTexture)
+    })
   })
 })