Procházet zdrojové kódy

feat: refactor to leverage useAsyncState under the hood

alvarosabu před 5 měsíci
rodič
revize
d5a56cef81

+ 51 - 49
docs/composables/use-texture.md

@@ -1,16 +1,23 @@
 # useTexture
 
-The `useTexture` composable allows you to load textures using the [THREE.js texture loader](https://threejs.org/docs/#api/en/loaders/TextureLoader). This composable provides a convenient way to load single or multiple textures with built-in loading state management.
+The `useTexture` composable allows you to load textures using the [THREE.js texture loader](https://threejs.org/docs/#api/en/loaders/TextureLoader). Built on top of VueUse's [useAsyncState](https://vueuse.org/core/useAsyncState/#useasyncstate), it provides a reactive way to load single or multiple textures with built-in loading state management.
 
 ## Features
 
 - 🔄 Reactive texture loading
-- 🔢 Support for single or multiple textures
+- 🗺️ Support for single or multiple textures
 - ⏳ Loading state tracking
 - ❌ Error handling
-- ⏱️ Async/await support with Suspense
 - 🔄 Manual reload capability
 
+## Implementation Details
+
+The composable is built on top of VueUse's `useAsyncState` to provide:
+- Automatic state management for loading, error, and data states
+- Reactive updates when texture paths change
+- Type-safe return values based on input type
+- Efficient handling of complex THREE.js objects
+
 ## Basic Usage
 
 ```ts
@@ -20,33 +27,24 @@ import { useTexture } from '@tresjs/core'
 ### Loading a Single Texture
 
 ```ts
-const { data: texture } = useTexture('path/to/texture.png')
+const { state: texture, isLoading } = useTexture('path/to/texture.png')
 ```
 
 ### Loading Multiple Textures
 
 ```ts
-const { data: textures } = useTexture([
+const [texture1, texture2] = useTexture([
   'path/to/albedo.png',
-  'path/to/normal.png',
-  'path/to/roughness.png'
+  'path/to/normal.png'
 ])
 
 // Access individual textures
-const [albedo, normal, roughness] = textures.value
+const albedo = texture1.state.value
+const normal = texture2.state.value
 ```
 
 ## Advanced Usage
 
-### With Async/Await and Suspense
-
-The composable can be awaited directly, making it compatible with Vue's Suspense component:
-
-```ts
-// In an async setup function
-const { data: texture } = await useTexture('path/to/texture.png')
-```
-
 ### Using a Custom Loading Manager
 
 You can provide a THREE.js LoadingManager to track loading progress across multiple resources:
@@ -59,7 +57,7 @@ manager.onProgress = (url, loaded, total) => {
   console.log(`Loading ${url}: ${loaded} of ${total} files.`)
 }
 
-const { data: texture } = useTexture('path/to/texture.png', manager)
+const { state: texture } = useTexture('path/to/texture.png', { manager })
 ```
 
 ### Handling Loading States
@@ -67,7 +65,7 @@ const { data: texture } = useTexture('path/to/texture.png', manager)
 The composable provides reactive references for tracking loading state:
 
 ```ts
-const { data: texture, isLoading, error } = useTexture('path/to/texture.png')
+const { state: texture, isLoading, error } = useTexture('path/to/texture.png')
 
 watch(isLoading, (value) => {
   if (value) {
@@ -85,16 +83,29 @@ watch(error, (value) => {
 ### Manual Loading
 
 ```ts
-const { data: texture, load } = useTexture('path/to/initial-texture.png')
+const { state: texture, execute } = useTexture('path/to/initial-texture.png')
 
 // Later, load a different texture
-const newTexture = await load('path/to/new-texture.png')
+execute(0, 'path/to/new-texture.png')
+```
 
-// Or load multiple textures
-const newTextures = await load([
-  'path/to/texture1.png',
-  'path/to/texture2.png'
-])
+### Reactive Path Handling
+
+The composable fully supports reactive paths using refs:
+
+```ts
+const texturePath = ref('https://example.com/texture1.png')
+const { state: texture, isLoading } = useTexture(texturePath)
+
+// Change the texture path and the composable will automatically reload
+texturePath.value = 'https://example.com/texture2.png'
+
+// You can also use computed paths
+const texturePath = computed(() => {
+  return isDarkMode.value
+    ? 'https://example.com/dark-texture.png'
+    : 'https://example.com/light-texture.png'
+})
 ```
 
 ## Common Use Cases
@@ -102,7 +113,7 @@ const newTextures = await load([
 ### Material Textures
 
 ```ts
-const { data: textures } = useTexture([
+const [albedo, normal, roughness, ao] = useTexture([
   'textures/wood/albedo.jpg',
   'textures/wood/normal.jpg',
   'textures/wood/roughness.jpg',
@@ -111,15 +122,13 @@ const { data: textures } = useTexture([
 
 // In your setup function
 const material = computed(() => {
-  if (!textures.value) { return null }
-
-  const [albedo, normal, roughness, ao] = textures.value
+  if (!albedo.state.value) { return null }
 
   return {
-    map: albedo,
-    normalMap: normal,
-    roughnessMap: roughness,
-    aoMap: ao
+    map: albedo.state.value,
+    normalMap: normal.state.value,
+    roughnessMap: roughness.state.value,
+    aoMap: ao.state.value
   }
 })
 ```
@@ -127,7 +136,7 @@ const material = computed(() => {
 ### Environment Maps
 
 ```ts
-const { data: envMap } = useTexture('textures/environment.hdr')
+const { state: envMap } = useTexture('textures/environment.hdr')
 
 // Use with a scene or material
 const scene = computed(() => {
@@ -144,7 +153,7 @@ const scene = computed(() => {
 ### Texture Atlas
 
 ```ts
-const { data: atlas } = useTexture('textures/sprite-atlas.png')
+const { state: atlas } = useTexture('textures/sprite-atlas.png')
 
 // Configure texture for sprite use
 watchEffect(() => {
@@ -162,23 +171,16 @@ watchEffect(() => {
 | Parameter | Type | Description |
 | --- | --- | --- |
 | `path` | `string \| string[]` | Path or array of paths to texture file(s) |
-| `manager` | `LoadingManager` | Optional THREE.js LoadingManager |
+| `options` | `{ manager?: LoadingManager, asyncOptions?: UseAsyncStateOptions }` | Optional configuration options |
 
 ### Returns
 
 | Property | Type | Description |
 | --- | --- | --- |
-| `data` | `Ref<Texture \| Texture[] \| null>` | The loaded texture(s) |
+| `state` | `Ref<Texture \| null>` | The loaded texture |
 | `isLoading` | `Ref<boolean>` | Whether the texture is currently loading |
-| `error` | `Ref<Error \| null>` | Any error that occurred during loading |
-| `promise` | `Promise<Texture \| Texture[]>` | Promise that resolves when the texture is loaded |
-| `load` | `Function` | Method to manually load texture(s) |
-
-## Notes
-
-- Textures are loaded both synchronously and asynchronously. The initial texture object is created immediately, but the actual image data loads asynchronously.
-- The composable uses `shallowRef` for better performance when dealing with complex THREE.js objects.
-- Error handling is built-in, with detailed error messages available in the `error` ref.
+| `error` | `Ref<Error \| undefined>` | Any error that occurred during loading |
+| `execute` | `Function` | Method to manually load texture |
 
 ## Component Usage
 
@@ -195,7 +197,7 @@ const paths = [
 </script>
 
 <template>
-  <UseTexture v-slot="{ data: texture }" :path="paths">
+  <UseTexture v-slot="{ state: texture }" :path="paths">
     <TresMesh :position="[-3, 1, 0]">
       <TresSphereGeometry :args="[1, 32, 32]" />
       <TresMeshStandardMaterial
@@ -214,4 +216,4 @@ The component provides the loaded texture(s) through its default slot prop. This
 - You need to ensure a mesh and its textures are loaded together
 - You prefer a more declarative template-based approach
 
-The slot provides the same properties as the composable (`data`, `isLoading`, `error`).
+The slot provides the same properties as the composable (`state`, `isLoading`, `error`).

+ 80 - 33
docs/cookbook/load-textures.md

@@ -16,10 +16,6 @@ Three-dimensional (3D) textures are images that contain multiple layers of data,
 
 There are two ways of loading 3D textures in TresJS:
 
-::: warning
-Please note that in the examples below use top level `await`. Make sure to wrap such code with a Vue's [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) component.
-:::
-
 ## Using `useLoader`
 
 The `useLoader` composable allows you to pass any type of three.js loader and a URL to load the resource from. It returns a `Promise` with the loaded resource.
@@ -30,7 +26,7 @@ For a detailed explanation of how to use `useLoader`, check out the [useLoader](
 import { useLoader } from '@tresjs/core'
 import { TextureLoader } from 'three'
 
-const texture = useLoader(TextureLoader, '/Rock035_2K_Color.jpg')
+const { state: texture } = useLoader(TextureLoader, '/Rock035_2K_Color.jpg')
 ```
 
 Then you can pass the texture to a material:
@@ -47,9 +43,7 @@ import TexturedSphere from './TexturedSphere.vue'
     shadows
     alpha
   >
-    <Suspense>
-      <TexturedSphere />
-    </Suspense>
+    <TexturedSphere />
   </TresCanvas>
 </template>
 ```
@@ -59,7 +53,7 @@ import TexturedSphere from './TexturedSphere.vue'
 import { useLoader } from '@tresjs/core'
 import { TextureLoader } from 'three'
 
-const texture = useLoader(TextureLoader, '/Rock035_2K_Color.jpg')
+const { state: texture, isLoading } = useLoader(TextureLoader, '/Rock035_2K_Color.jpg')
 </script>
 
 <template>
@@ -73,35 +67,88 @@ const texture = useLoader(TextureLoader, '/Rock035_2K_Color.jpg')
 
 ## Using `useTexture`
 
-A more convenient way of loading textures is using the `useTexture` composable. It accepts both an array of URLs or a single object with the texture paths mapped.
+A more convenient way of loading textures is using the `useTexture` composable. It provides reactive state management and supports both single textures and arrays of textures.
 
 To learn more about `useTexture`, check out the [useTexture](/api/composables#use-texture) documentation.
 
+### Loading a Single Texture
+
+```ts
+import { useTexture } from '@tresjs/core'
+
+const { state: texture, isLoading } = useTexture('/textures/black-rock/Rock035_2K_Color.jpg')
+```
+
+### Loading Multiple Textures
+
 ```ts
 import { useTexture } from '@tresjs/core'
 
-const pbrTexture = await useTexture({
-  map: '/textures/black-rock/Rock035_2K_Displacement.jpg',
-  displacementMap: '/textures/black-rock/Rock035_2K_Displacement.jpg',
-  roughnessMap: '/textures/black-rock/Rock035_2K_Roughness.jpg',
-  normalMap: '/textures/black-rock/Rock035_2K_NormalDX.jpg',
-  aoMap: '/textures/black-rock/Rock035_2K_AmbientOcclusion.jpg',
-  metalnessMap: '/textures/black-rock/myMetalnessTexture.jpg',
-  matcap: '/textures/black-rock/myMatcapTexture.jpg',
-  alphaMap: '/textures/black-rock/myAlphaMapTexture.jpg'
-})
+const [albedo, normal, roughness] = useTexture([
+  '/textures/black-rock/Rock035_2K_Color.jpg',
+  '/textures/black-rock/Rock035_2K_NormalDX.jpg',
+  '/textures/black-rock/Rock035_2K_Roughness.jpg'
+])
+```
+
+Then you can use the textures in your material:
+
+```vue
+<template>
+  <TresMesh>
+    <TresSphereGeometry :args="[1, 32, 32]" />
+    <TresMeshStandardMaterial
+      :map="albedo.state.value"
+      :normal-map="normal.state.value"
+      :roughness-map="roughness.state.value"
+    />
+    <Html transform position-y="1.5">
+      <span class="text-xs bg-white p-2 rounded-md">
+        {{ albedo.isLoading.value ? 'Loading...' : 'Loaded' }}
+      </span>
+    </Html>
+  </TresMesh>
+</template>
+```
+
+### Reactive Texture Loading
+
+The composable supports reactive paths, allowing you to change textures dynamically:
+
+```ts
+const texturePath = ref('/textures/black-rock/Rock035_2K_Color.jpg')
+const { state: texture, isLoading } = useTexture(texturePath)
+
+// Later, change the texture
+texturePath.value = '/textures/hexagonal-rock/Rocks_Hexagons_002_basecolor.jpg'
 ```
-Similar to the previous example, we can pass all the textures to a material via props:
-
-```html
-<TresMesh>
-  <TresSphereGeometry :args="[1,32,32]" />
-  <TresMeshStandardMaterial
-    :map="pbrTexture.map"
-    :displacementMap="pbrTexture.displacementMap"
-    :roughnessMap="pbrTexture.roughnessMap"
-    :normalMap="pbrTexture.normalMap"
-    :aoMap="pbrTexture.ambientOcclusionMap"
-  />
-</TresMesh>
+
+### Using the UseTexture Component
+
+For a more declarative approach, you can use the `UseTexture` component:
+
+```vue
+<script setup lang="ts">
+import { UseTexture } from '@tresjs/core'
+
+const paths = [
+  '/textures/black-rock/Rock035_2K_Color.jpg',
+  '/textures/black-rock/Rock035_2K_NormalDX.jpg',
+  '/textures/black-rock/Rock035_2K_Roughness.jpg'
+]
+</script>
+
+<template>
+  <UseTexture v-slot="{ state: textures, isLoading }" :path="paths">
+    <TresMesh>
+      <TresSphereGeometry :args="[1, 32, 32]" />
+      <TresMeshStandardMaterial
+        v-if="!isLoading"
+        :map="textures[0]"
+        :normal-map="textures[1]"
+        :roughness-map="textures[2]"
+      />
+    </TresMesh>
+  </UseTexture>
+</template>
 ```

+ 13 - 10
playground/vue/src/pages/composables/useTexture/ObjectSyncLoadMultipleTexture.vue → playground/vue/src/pages/composables/useTexture/MultipleTexture.vue

@@ -1,35 +1,38 @@
 <script setup lang="ts">
-/* eslint-disable no-console */
 import { Html } from '@tresjs/cientos'
 import { useTexture } from '@tresjs/core'
+import { ref } from 'vue'
 
-const { data: texture, load } = useTexture([
+const texturePath = ref([
   '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',
 ])
+const [texture, displacement] = useTexture(texturePath)
 
-watch(texture, (newVal) => {
-  console.log('Load mutiple', newVal)
-}, { immediate: true })
+const { state: textureState, isLoading: textureIsLoading } = texture
+const { state: displacementState, isLoading: displacementIsLoading } = displacement
 
+const isLoading = computed(() => textureIsLoading.value || displacementIsLoading.value)
+
+// Change the texture path after 2 seconds
 setTimeout(() => {
-  load([
+  texturePath.value = [
     '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]">
+  <TresMesh :position="[4, 1, 0]">
     <Html transform position-y="1.5">
       <span class="text-xs bg-white p-2 rounded-md">
-        Load multiple
+        Multiple {{ isLoading ? 'Loading...' : 'Loaded' }}
       </span>
     </Html>
     <TresSphereGeometry :args="[1, 32, 32]" />
-    <TresMeshStandardMaterial :map="texture[0]" :displacement-map="texture[1]" :displacement-scale="0.1" />
+    <TresMeshStandardMaterial v-if="!isLoading" :map="textureState" :displacement-map="displacementState" :displacement-scale="0.1" />
   </TresMesh>
 </template>

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

@@ -1,28 +0,0 @@
-<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>

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

@@ -1,25 +0,0 @@
-<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>

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

@@ -1,29 +0,0 @@
-<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>

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

@@ -1,36 +0,0 @@
-<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>

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

@@ -1,29 +0,0 @@
-<script setup lang="ts">
-/* eslint-disable no-console */
-import { Html } from '@tresjs/cientos'
-import { useTexture } from '@tresjs/core'
-
-const { data: texture, isLoading } = 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>

+ 2 - 2
playground/vue/src/pages/composables/useTexture/ObjectUseTextureComponent.vue

@@ -9,7 +9,7 @@ const paths = [
 </script>
 
 <template>
-  <UseTexture v-slot="{ data: texture }" :path="paths">
+  <UseTexture v-slot="states" :path="paths">
     <TresMesh :position="[-3, 1, 0]">
       <Html transform position-y="1.5">
         <span class="text-xs bg-white p-2 rounded-md">
@@ -17,7 +17,7 @@ const paths = [
         </span>
       </Html>
       <TresSphereGeometry :args="[1, 32, 32]" />
-      <TresMeshStandardMaterial v-if="texture" :map="texture[0]" :displacement-map="texture[1]" :displacement-scale="0.1" />
+      <TresMeshStandardMaterial v-if="states[0].state.value && states[1].state.value" :map="states[0].state.value" :displacement-map="states[1].state.value" :displacement-scale="0.1" />
     </TresMesh>
   </UseTexture>
 </template>

+ 37 - 0
playground/vue/src/pages/composables/useTexture/SimpleTexture.vue

@@ -0,0 +1,37 @@
+<script setup lang="ts">
+/* eslint-disable no-console */
+
+import { Html } from '@tresjs/cientos'
+import { useTexture } from '@tresjs/core'
+import { ref, watch } from 'vue'
+
+const texturePath = ref('https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Color.jpg')
+const { state: texture, isLoading } = useTexture(texturePath)
+
+// Change the texture path after 2 seconds
+setTimeout(() => {
+  texturePath.value = 'https://raw.githubusercontent.com/Tresjs/assets/main/textures/hexagonal-rock/Rocks_Hexagons_002_basecolor.jpg'
+}, 2000)
+
+watch(isLoading, (newVal) => {
+  console.log('Outside useTexture isLoading', newVal)
+}, { immediate: true })
+
+watch(texture, (newVal) => {
+  console.log('Outside useTexture texture', newVal)
+}, { immediate: true })
+
+/* eslint-enable no-console */
+</script>
+
+<template>
+  <TresMesh :position="[0, 1, 0]">
+    <Html transform position-y="1.5">
+      <span class="text-xs bg-white p-2 rounded-md">
+        Simple {{ isLoading ? 'Loading...' : 'Loaded' }}
+      </span>
+    </Html>
+    <TresSphereGeometry :args="[1, 32, 32]" />
+    <TresMeshStandardMaterial :map="texture" />
+  </TresMesh>
+</template>

+ 6 - 17
playground/vue/src/pages/composables/useTexture/index.vue

@@ -3,13 +3,10 @@ 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 SimpleTexture from './SimpleTexture.vue'
+import MultipleTexture from './MultipleTexture.vue'
 import ObjectUseTextureComponent from './ObjectUseTextureComponent.vue'
-import ObjectSyncLoadSimpleTexture from './ObjectSyncLoadSimpleTexture.vue'
-import ObjectSyncLoadMultipleTexture from './ObjectSyncLoadMultipleTexture.vue'
 
 const gl = {
   clearColor: '#82DBC5',
@@ -23,20 +20,12 @@ const gl = {
 
 <template>
   <TresCanvas v-bind="gl">
-    <TresPerspectiveCamera :position="[8, 8, 8]" />
+    <TresPerspectiveCamera :position="[0, 1, 16]" />
     <OrbitControls />
     <TresGridHelper :args="[100, 100]" />
     <TresAmbientLight :intensity="1" />
-    <ObjectSyncSimpleTexture />
-    <ObjectSyncMultipleTexture />
-    <Suspense>
-      <ObjectAsyncSimpleTexture />
-    </Suspense>
-    <Suspense>
-      <ObjectAsyncMultipleTexture />
-    </Suspense>
+    <SimpleTexture />
+    <MultipleTexture />
     <ObjectUseTextureComponent />
-    <ObjectSyncLoadSimpleTexture />
-    <ObjectSyncLoadMultipleTexture />
   </TresCanvas>
 </template>

+ 32 - 20
src/composables/useTexture/component.vue

@@ -1,38 +1,50 @@
-<script setup lang="ts">
-import { reactive } from 'vue'
+<script setup lang="ts" generic="T extends string | string[]">
 import type { LoadingManager, Texture } from 'three'
-import { useTexture, type UseTextureReturn } from './index'
+import { useTexture } from './index'
+import { computed, defineEmits, defineProps } from 'vue'
+import { whenever } from '@vueuse/core'
 
 const props = defineProps<{
   /**
    * Path or array of paths to texture(s)
-   */
-  path: string | string[]
+    */
+  path: T
   /**
    * Optional THREE.js LoadingManager
    */
   manager?: LoadingManager
 }>()
+
 const emit = defineEmits<{
-  (e: 'loaded'): void
-  (e: 'error', error: Error): void
+  loaded: [texture: T extends string ? Texture : Texture[]]
+  error: [error: T extends string ? unknown : unknown[]] // this is unknown because TextureLoader's error type is unknown
 }>()
 
-// 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))
+const state = useTexture(props.path, { manager: props.manager })
+
+const isMultiple = (x: ReturnType<typeof useTexture<string, any>> | ReturnType<typeof useTexture<string[], any>>): x is ReturnType<typeof useTexture<string[], any>> => Array.isArray(x)
+
+const errorOrErrors = computed(() =>
+  (isMultiple(state) ? state.map(({ error }) => error.value) : state.error.value),
+)
+const hasError = computed(() =>
+  isMultiple(state) ? state.some(({ error }) => error.value) : !!state.error.value,
+)
+whenever(hasError, () => {
+  emit('error', errorOrErrors.value as unknown as T extends string ? unknown : unknown[])
+})
 
-// Handle loading state
-textureData.promise
-  .then(() => emit('loaded'))
-  .catch(err => emit('error', err))
+const isReady = computed(() => {
+  return isMultiple(state) ? state.every(({ isReady }) => isReady.value) : !!state.isReady
+})
+const dataOrDataArray = computed(() =>
+  isMultiple(state) ? state.map(({ state }) => state.value) : state.state.value,
+)
+whenever(isReady, () => {
+  emit('loaded', dataOrDataArray.value as unknown as T extends string ? Texture : Texture[])
+})
 </script>
 
 <template>
-  <slot
-    :data="textureData.data"
-    :is-loading="textureData.isLoading"
-    :error="textureData.error"
-  ></slot>
+  <slot v-bind="state"></slot>
 </template>

+ 62 - 126
src/composables/useTexture/index.ts

@@ -1,140 +1,76 @@
-import { ref, type Ref, shallowRef } from 'vue'
-import type { LoadingManager, Texture } from 'three'
-import { TextureLoader } from 'three'
-
-export interface UseTextureReturn<T> {
-  /**
-   * The loaded texture(s)
-   */
-  data: Ref<T | null>
-  /**
-   * Whether the texture is currently loading
-   */
-  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[]>
-  }
-}
+import { type MaybeRef, useAsyncState, type UseAsyncStateOptions, type UseAsyncStateReturn } from '@vueuse/core'
+import { type LoadingManager, Texture, TextureLoader } from 'three'
+import { onUnmounted, toValue, watch } from 'vue'
 
 /**
- * Vue composable for loading textures with THREE.js
- * Can be used with or without await/Suspense
- *
- * @example
- * ```ts
- * import { useTexture } from '@tresjs/core'
- *
- * // Single texture
- * const { data: texture } = useTexture('path/to/texture.png')
- *
- * // Multiple textures - returns array of textures
- * const { data: textures } = useTexture([
- *   'path/to/albedo.png',
- *   'path/to/displacement.png'
- * ])
- * // Access individual textures
- * const [albedo, displacement] = textures.value
- *
- * // With async/await
- * const { data } = await useTexture('texture.png')
- * ```
- *
- * @param path - Path or paths to texture(s)
- * @param manager - Optional THREE.js LoadingManager
+ * Type representing a texture path or multiple texture paths
  */
-export function useTexture(path: string, manager?: LoadingManager): UseTextureReturn<Texture> & Promise<UseTextureReturn<Texture>>
-export function useTexture(paths: string[], manager?: LoadingManager): UseTextureReturn<Texture[]> & Promise<UseTextureReturn<Texture[]>>
-export function useTexture(
-  paths: string | string[],
-  manager?: LoadingManager,
-): UseTextureReturn<Texture | Texture[]> & Promise<UseTextureReturn<Texture | Texture[]>> {
-  const data = shallowRef<Texture | Texture[] | null>(null)
-  const isLoading = ref(true)
-  const error = ref<Error | null>(null)
+export type TexturePath = string | string[]
 
+export function useTexture<T extends MaybeRef<TexturePath>, Shallow extends boolean>(
+  path: T,
+  {
+    manager,
+    asyncOptions,
+  }: {
+    manager?: LoadingManager
+    asyncOptions?: UseAsyncStateOptions<Shallow, Texture | null>
+  } = {},
+):
+  T extends MaybeRef<string> ?
+    UseAsyncStateReturn<Texture | null, [TexturePath], Shallow> :
+    UseAsyncStateReturn<Texture | null, [TexturePath], Shallow>[] {
+  const immediate = asyncOptions?.immediate ?? true
   const textureLoader = new TextureLoader(manager)
 
-  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
-      }
-    })
+  const loadTexture = (initialPath?: string) => useAsyncState(
+    (path: string) =>
+      textureLoader.loadAsync(path || initialPath || ''),
+    new Texture(),
+    {
+      ...asyncOptions,
+      immediate,
+    },
+  )
 
-  // Overloaded load function
-  const load = ((paths: string | string[]): Promise<Texture | Texture[]> => {
-    isLoading.value = true
-    error.value = null
+  const initialPath = toValue(path)
 
-    if (typeof paths === 'string') {
-      const texture = textureLoader.load(paths)
-      data.value = texture
-      return loadTexture(paths)
-    }
-    else {
-      // 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']
+  // Create a type-safe result variable
+  let singleResult: UseAsyncStateReturn<Texture | null, [TexturePath], Shallow> | undefined
+  let arrayResult: UseAsyncStateReturn<Texture | null, [TexturePath], Shallow>[] | undefined
 
-  // Make the return value awaitable
-  const returnValue = {
-    data,
-    isLoading,
-    error,
-    load,
-  } as UseTextureReturn<Texture | Texture[]> & Promise<UseTextureReturn<Texture | Texture[]>>
-
-  // Initial load
-  if (typeof paths === 'string') {
-    const texture = textureLoader.load(paths)
-    data.value = texture
-    returnValue.promise = loadTexture(paths)
+  if (typeof initialPath === 'string') {
+    singleResult = loadTexture(initialPath) as UseAsyncStateReturn<Texture | null, [TexturePath], Shallow>
   }
   else {
-    // 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)))
+    arrayResult = (initialPath as string[]).map(path =>
+      loadTexture(path),
+    ) as UseAsyncStateReturn<Texture | null, [TexturePath], Shallow>[]
   }
 
-  return returnValue
+  const unsub = watch(() => toValue(path), (newPath) => {
+    if (newPath) {
+      if (typeof newPath === 'string' && singleResult) {
+        // Handle single path update
+        singleResult.execute(0, newPath)
+      }
+      else if (Array.isArray(newPath) && arrayResult) {
+        // Handle array of paths update
+        newPath.forEach((path, index) => {
+          if (arrayResult && index < arrayResult.length) {
+            arrayResult[index].execute(0, path)
+          }
+        })
+      }
+    }
+  })
+
+  onUnmounted(() => {
+    unsub()
+  })
+
+  // Return the appropriate result based on the input type
+  return (singleResult || arrayResult) as T extends MaybeRef<string> ?
+    UseAsyncStateReturn<Texture | null, [TexturePath], Shallow> :
+    UseAsyncStateReturn<Texture | null, [TexturePath], Shallow>[]
 }

+ 46 - 90
src/composables/useTexture/useTexture.test.ts

@@ -1,6 +1,7 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { LoadingManager, Texture, TextureLoader } from 'three'
+import { Texture, TextureLoader } from 'three'
 import { useTexture } from '.'
+import { ref } from 'vue'
 
 const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0))
 
@@ -26,31 +27,21 @@ describe('useTexture', () => {
   })
 
   describe('single texture', () => {
-    it('should return texture synchronously', () => {
-      const { data, isLoading, error } = useTexture(mockTextureUrl)
+    it('should load a single texture', async () => {
+      const { state, 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)
+      expect(state.value).toBeDefined()
+      expect(state.value).toBeInstanceOf(Texture)
       await flushPromises()
-      expect(isLoading.value).toBe(false)
-      expect(error.value).toBe(null)
+      expect(error.value).toBeUndefined()
     })
 
-    it('should work with async/await', async () => {
-      const { data, isLoading, error } = await useTexture(mockTextureUrl)
+    it('should update loading state when texture image loads', async () => {
+      const { isLoading } = 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 () => {
@@ -60,89 +51,54 @@ describe('useTexture', () => {
         return mockTexture
       })
 
-      const { isLoading, error, promise } = useTexture(mockTextureUrl)
-
-      // Catch the promise rejection to prevent unhandled rejection
-      await promise.catch(() => {
-        // Expected to reject
-      })
+      const { isLoading, error } = useTexture(mockTextureUrl)
 
       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((error.value as Error).message).toContain('Failed to load texture')
       expect(isLoading.value).toBe(false)
-      expect(error.value).toBe(null)
     })
 
-    it('should work with async/await', async () => {
-      const { data, isLoading, error } = await useTexture(mockMultipleUrls)
+    describe('multiple textures', () => {
+      it('should load multiple textures', async () => {
+        const [texture1, texture2] = 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
+        expect(texture1.state.value).toBeInstanceOf(Texture)
+        expect(texture2.state.value).toBeInstanceOf(Texture)
+        expect(texture1.isLoading.value).toBe(true)
+        await flushPromises()
+        expect(texture1.isLoading.value).toBe(false)
+        expect(texture1.error.value).toBeUndefined()
       })
-
-      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)
+    describe('reactive texture loading', () => {
+      it('should update texture when path changes', async () => {
+        const texturePath = ref(mockTextureUrl)
+        const { state, isLoading } = useTexture(texturePath)
+
+        texturePath.value = 'https://example.com/new-texture.png'
+
+        // Initial texture
+        expect(state.value).toBeInstanceOf(Texture)
+        await flushPromises()
+        expect(isLoading.value).toBe(false)
+
+        // Change texture path
+        const newTexture = new Texture()
+        const newTextureUrl = 'https://example.com/new-texture.png'
+        vi.spyOn(TextureLoader.prototype, 'load').mockImplementation((_, onLoad) => {
+          if (onLoad) { setTimeout(() => onLoad(newTexture), 0) }
+          return newTexture
+        })
+
+        texturePath.value = newTextureUrl
+        await flushPromises()
+        expect(isLoading.value).toBe(false)
+        // Check the second call (index 1) to load
+        expect(TextureLoader.prototype.load).toHaveBeenNthCalledWith(2, newTextureUrl, expect.any(Function), undefined, expect.any(Function))
+      })
     })
   })
 })

+ 31 - 0
src/utils/loaders.ts

@@ -0,0 +1,31 @@
+import { type LoadingManager, type Texture, TextureLoader } from 'three'
+
+/**
+ * Creates a configured texture loader and loads a texture synchronously
+ * @param {string} url - URL of the texture to load
+ * @param {object} options - Optional configuration
+ * @param {LoadingManager} options.manager - Optional loading manager
+ * @param {(event: ProgressEvent) => void} options.onProgress - Optional progress callback
+ * @param {(error: ErrorEvent) => void} options.onError - Optional error callback
+ * @returns {Texture} The loaded texture
+ */
+export function loadTextureSync(
+  url: string,
+  {
+    manager,
+    onProgress,
+    onError,
+  }: {
+    manager?: LoadingManager
+    onProgress?: (event: ProgressEvent) => void
+    onError?: (error: ErrorEvent) => void
+  } = {},
+): Texture {
+  const loader = new TextureLoader(manager)
+  return loader.load(
+    url,
+    undefined,
+    onProgress,
+    onError,
+  )
+}