[READ-ONLY] Mirror of https://github.com/flo-bit/tiny-creature. three.js journey #16 - tamagotchi flo-bit.dev/tiny-creature/
0

Configure Feed

Select the types of activity you want to include in your feed.

add pane

Florian (Feb 8, 2025, 2:44 AM +0100) a5e79f96 c617f873

+853 -242
+284
package-lock.json
··· 15 15 "@types/three": "^0.173.0", 16 16 "pixi.js": "^8.7.3", 17 17 "stats.js": "^0.17.0", 18 + "svelte-tweakpane-ui": "^1.5.2", 18 19 "three": "^0.173.0", 20 + "three-shader-baker": "^1.0.8", 19 21 "wiggle": "^0.0.17" 20 22 }, 21 23 "devDependencies": { ··· 64 66 }, 65 67 "engines": { 66 68 "node": ">=6.0.0" 69 + } 70 + }, 71 + "node_modules/@babel/runtime": { 72 + "version": "7.26.7", 73 + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", 74 + "integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", 75 + "peer": true, 76 + "dependencies": { 77 + "regenerator-runtime": "^0.14.0" 78 + }, 79 + "engines": { 80 + "node": ">=6.9.0" 67 81 } 68 82 }, 69 83 "node_modules/@esbuild/aix-ppc64": { ··· 1201 1215 "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", 1202 1216 "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==" 1203 1217 }, 1218 + "node_modules/@react-three/fiber": { 1219 + "version": "8.17.14", 1220 + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.17.14.tgz", 1221 + "integrity": "sha512-Al2Zdhn5vRefK0adJXNDputuM8hwRNh3goH8MCzf06gezZBbEsdmjt5IrHQQ8Rpr7l/znx/ipLUQuhiiVhxifQ==", 1222 + "peer": true, 1223 + "dependencies": { 1224 + "@babel/runtime": "^7.17.8", 1225 + "@types/react-reconciler": "^0.26.7", 1226 + "@types/webxr": "*", 1227 + "base64-js": "^1.5.1", 1228 + "buffer": "^6.0.3", 1229 + "its-fine": "^1.0.6", 1230 + "react-reconciler": "^0.27.0", 1231 + "react-use-measure": "^2.1.7", 1232 + "scheduler": "^0.21.0", 1233 + "suspend-react": "^0.1.3", 1234 + "zustand": "^3.7.1" 1235 + }, 1236 + "peerDependencies": { 1237 + "expo": ">=43.0", 1238 + "expo-asset": ">=8.4", 1239 + "expo-file-system": ">=11.0", 1240 + "expo-gl": ">=11.0", 1241 + "react": ">=18 <19", 1242 + "react-dom": ">=18 <19", 1243 + "react-native": ">=0.64", 1244 + "three": ">=0.133" 1245 + }, 1246 + "peerDependenciesMeta": { 1247 + "expo": { 1248 + "optional": true 1249 + }, 1250 + "expo-asset": { 1251 + "optional": true 1252 + }, 1253 + "expo-file-system": { 1254 + "optional": true 1255 + }, 1256 + "expo-gl": { 1257 + "optional": true 1258 + }, 1259 + "react-dom": { 1260 + "optional": true 1261 + }, 1262 + "react-native": { 1263 + "optional": true 1264 + } 1265 + } 1266 + }, 1204 1267 "node_modules/@rollup/rollup-android-arm-eabi": { 1205 1268 "version": "4.34.1", 1206 1269 "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.1.tgz", ··· 1654 1717 "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", 1655 1718 "dev": true 1656 1719 }, 1720 + "node_modules/@types/react": { 1721 + "version": "19.0.8", 1722 + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", 1723 + "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", 1724 + "peer": true, 1725 + "dependencies": { 1726 + "csstype": "^3.0.2" 1727 + } 1728 + }, 1729 + "node_modules/@types/react-reconciler": { 1730 + "version": "0.26.7", 1731 + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", 1732 + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", 1733 + "peer": true, 1734 + "dependencies": { 1735 + "@types/react": "*" 1736 + } 1737 + }, 1657 1738 "node_modules/@types/stats.js": { 1658 1739 "version": "0.17.3", 1659 1740 "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz", ··· 2059 2140 "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 2060 2141 "dev": true 2061 2142 }, 2143 + "node_modules/base64-js": { 2144 + "version": "1.5.1", 2145 + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", 2146 + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", 2147 + "funding": [ 2148 + { 2149 + "type": "github", 2150 + "url": "https://github.com/sponsors/feross" 2151 + }, 2152 + { 2153 + "type": "patreon", 2154 + "url": "https://www.patreon.com/feross" 2155 + }, 2156 + { 2157 + "type": "consulting", 2158 + "url": "https://feross.org/support" 2159 + } 2160 + ], 2161 + "peer": true 2162 + }, 2062 2163 "node_modules/bidi-js": { 2063 2164 "version": "1.0.3", 2064 2165 "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", ··· 2141 2242 "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" 2142 2243 } 2143 2244 }, 2245 + "node_modules/buffer": { 2246 + "version": "6.0.3", 2247 + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", 2248 + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", 2249 + "funding": [ 2250 + { 2251 + "type": "github", 2252 + "url": "https://github.com/sponsors/feross" 2253 + }, 2254 + { 2255 + "type": "patreon", 2256 + "url": "https://www.patreon.com/feross" 2257 + }, 2258 + { 2259 + "type": "consulting", 2260 + "url": "https://feross.org/support" 2261 + } 2262 + ], 2263 + "peer": true, 2264 + "dependencies": { 2265 + "base64-js": "^1.3.1", 2266 + "ieee754": "^1.2.1" 2267 + } 2268 + }, 2144 2269 "node_modules/callsites": { 2145 2270 "version": "3.1.0", 2146 2271 "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", ··· 2291 2416 "engines": { 2292 2417 "node": ">=4" 2293 2418 } 2419 + }, 2420 + "node_modules/csstype": { 2421 + "version": "3.1.3", 2422 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", 2423 + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", 2424 + "peer": true 2294 2425 }, 2295 2426 "node_modules/debug": { 2296 2427 "version": "4.4.0", ··· 2985 3116 "node": ">= 0.4" 2986 3117 } 2987 3118 }, 3119 + "node_modules/ieee754": { 3120 + "version": "1.2.1", 3121 + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", 3122 + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", 3123 + "funding": [ 3124 + { 3125 + "type": "github", 3126 + "url": "https://github.com/sponsors/feross" 3127 + }, 3128 + { 3129 + "type": "patreon", 3130 + "url": "https://www.patreon.com/feross" 3131 + }, 3132 + { 3133 + "type": "consulting", 3134 + "url": "https://feross.org/support" 3135 + } 3136 + ], 3137 + "peer": true 3138 + }, 2988 3139 "node_modules/ignore": { 2989 3140 "version": "5.3.2", 2990 3141 "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", ··· 3129 3280 "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", 3130 3281 "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==" 3131 3282 }, 3283 + "node_modules/its-fine": { 3284 + "version": "1.2.5", 3285 + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", 3286 + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", 3287 + "peer": true, 3288 + "dependencies": { 3289 + "@types/react-reconciler": "^0.28.0" 3290 + }, 3291 + "peerDependencies": { 3292 + "react": ">=18.0" 3293 + } 3294 + }, 3295 + "node_modules/its-fine/node_modules/@types/react-reconciler": { 3296 + "version": "0.28.9", 3297 + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", 3298 + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", 3299 + "peer": true, 3300 + "peerDependencies": { 3301 + "@types/react": "*" 3302 + } 3303 + }, 3132 3304 "node_modules/jackspeak": { 3133 3305 "version": "3.4.3", 3134 3306 "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", ··· 3152 3324 "bin": { 3153 3325 "jiti": "bin/jiti.js" 3154 3326 } 3327 + }, 3328 + "node_modules/js-tokens": { 3329 + "version": "4.0.0", 3330 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 3331 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 3332 + "peer": true 3155 3333 }, 3156 3334 "node_modules/js-yaml": { 3157 3335 "version": "4.1.0", ··· 3282 3460 "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", 3283 3461 "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", 3284 3462 "dev": true 3463 + }, 3464 + "node_modules/loose-envify": { 3465 + "version": "1.4.0", 3466 + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 3467 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 3468 + "peer": true, 3469 + "dependencies": { 3470 + "js-tokens": "^3.0.0 || ^4.0.0" 3471 + }, 3472 + "bin": { 3473 + "loose-envify": "cli.js" 3474 + } 3285 3475 }, 3286 3476 "node_modules/lru-cache": { 3287 3477 "version": "10.4.3", ··· 3987 4177 } 3988 4178 ] 3989 4179 }, 4180 + "node_modules/react": { 4181 + "version": "18.3.1", 4182 + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", 4183 + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", 4184 + "peer": true, 4185 + "dependencies": { 4186 + "loose-envify": "^1.1.0" 4187 + }, 4188 + "engines": { 4189 + "node": ">=0.10.0" 4190 + } 4191 + }, 4192 + "node_modules/react-reconciler": { 4193 + "version": "0.27.0", 4194 + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", 4195 + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", 4196 + "peer": true, 4197 + "dependencies": { 4198 + "loose-envify": "^1.1.0", 4199 + "scheduler": "^0.21.0" 4200 + }, 4201 + "engines": { 4202 + "node": ">=0.10.0" 4203 + }, 4204 + "peerDependencies": { 4205 + "react": "^18.0.0" 4206 + } 4207 + }, 4208 + "node_modules/react-use-measure": { 4209 + "version": "2.1.7", 4210 + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", 4211 + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", 4212 + "peer": true, 4213 + "peerDependencies": { 4214 + "react": ">=16.13", 4215 + "react-dom": ">=16.13" 4216 + }, 4217 + "peerDependenciesMeta": { 4218 + "react-dom": { 4219 + "optional": true 4220 + } 4221 + } 4222 + }, 3990 4223 "node_modules/read-cache": { 3991 4224 "version": "1.0.0", 3992 4225 "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", ··· 4007 4240 "type": "individual", 4008 4241 "url": "https://paulmillr.com/funding/" 4009 4242 } 4243 + }, 4244 + "node_modules/regenerator-runtime": { 4245 + "version": "0.14.1", 4246 + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", 4247 + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", 4248 + "peer": true 4010 4249 }, 4011 4250 "node_modules/require-from-string": { 4012 4251 "version": "2.0.2", ··· 4143 4382 }, 4144 4383 "optionalDependencies": { 4145 4384 "@parcel/watcher": "^2.4.1" 4385 + } 4386 + }, 4387 + "node_modules/scheduler": { 4388 + "version": "0.21.0", 4389 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", 4390 + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", 4391 + "peer": true, 4392 + "dependencies": { 4393 + "loose-envify": "^1.1.0" 4146 4394 } 4147 4395 }, 4148 4396 "node_modules/semver": { ··· 4375 4623 "url": "https://github.com/sponsors/ljharb" 4376 4624 } 4377 4625 }, 4626 + "node_modules/suspend-react": { 4627 + "version": "0.1.3", 4628 + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", 4629 + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", 4630 + "peer": true, 4631 + "peerDependencies": { 4632 + "react": ">=17.0" 4633 + } 4634 + }, 4378 4635 "node_modules/svelte": { 4379 4636 "version": "5.19.6", 4380 4637 "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.19.6.tgz", ··· 4799 5056 "integrity": "sha512-rqwnl/pUa7+inhI2E9ayGTqqP0EPOOn/wVvSWjZsRbZUItzNShny7pzwL3hVlaN4m9t/aZhsP0aFQ9U5VVR2VQ==", 4800 5057 "funding": { 4801 5058 "url": "https://github.com/sponsors/cocopon" 5059 + } 5060 + }, 5061 + "node_modules/three-shader-baker": { 5062 + "version": "1.0.8", 5063 + "resolved": "https://registry.npmjs.org/three-shader-baker/-/three-shader-baker-1.0.8.tgz", 5064 + "integrity": "sha512-YU6A19fety2zeD5pc5qMGyDi/6DLIJsfyoQo8AP1Z1VAZ8iFzENIbUYfOAANUjdLRVe69I5dv2hTiEK239FPnw==", 5065 + "peerDependencies": { 5066 + "@react-three/fiber": ">=8.0", 5067 + "react": ">=18.0", 5068 + "three": ">=0.163" 4802 5069 } 4803 5070 }, 4804 5071 "node_modules/three-viewport-gizmo": { ··· 5244 5511 "version": "1.1.2", 5245 5512 "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", 5246 5513 "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==" 5514 + }, 5515 + "node_modules/zustand": { 5516 + "version": "3.7.2", 5517 + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", 5518 + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", 5519 + "peer": true, 5520 + "engines": { 5521 + "node": ">=12.7.0" 5522 + }, 5523 + "peerDependencies": { 5524 + "react": ">=16.8" 5525 + }, 5526 + "peerDependenciesMeta": { 5527 + "react": { 5528 + "optional": true 5529 + } 5530 + } 5247 5531 } 5248 5532 } 5249 5533 }
+2
package.json
··· 44 44 "@types/three": "^0.173.0", 45 45 "pixi.js": "^8.7.3", 46 46 "stats.js": "^0.17.0", 47 + "svelte-tweakpane-ui": "^1.5.2", 47 48 "three": "^0.173.0", 49 + "three-shader-baker": "^1.0.8", 48 50 "wiggle": "^0.0.17" 49 51 } 50 52 }
+86 -37
src/lib/Creature.svelte
··· 4 4 // @ts-ignore 5 5 import { WiggleBone } from 'wiggle'; 6 6 import { onMount } from 'svelte'; 7 - import { createFaceTexture, createFurTexture, updateFace } from './face/face'; 8 - import { T, useTask } from '@threlte/core'; 9 - 10 - let rootBone: THREE.Bone; 11 - const wiggleBones: WiggleBone[] = []; 12 - let creature: THREE.Group; 13 - 14 - let canvasTexture: THREE.CanvasTexture; 15 - let furTexture: THREE.CanvasTexture; 16 - 7 + import { createFaceTexture, createFurTexture, updateBackground, updateFace } from './face/face'; 8 + import { T, useTask, useThrelte } from '@threlte/core'; 17 9 import { 10 + add, 18 11 color, 19 12 float, 20 13 Fn, 14 + hash, 21 15 mix, 16 + mul, 22 17 mx_noise_float, 23 18 normalGeometry, 24 19 positionGeometry, 25 20 select, 26 21 texture, 27 - vec3 22 + transformedNormalView, 23 + uniform, 24 + vec2, 25 + vec3, 26 + vec4 28 27 } from 'three/tsl'; 28 + import { colors } from './state.svelte'; 29 + 30 + let rootBone: THREE.Bone; 31 + const wiggleBones: WiggleBone[] = []; 32 + let creature: THREE.Group | null = $state(null); 33 + 34 + let canvasTexture: THREE.CanvasTexture; 35 + let furTexture: THREE.CanvasTexture; 36 + 37 + const { canvas } = useThrelte(); 38 + 39 + let mouse = new THREE.Vector2(); 29 40 30 41 function jump() { 31 42 ySpeed = 5; 32 43 } 33 44 45 + let primaryColors = []; 46 + let secondaryColors = []; 47 + 34 48 onMount(async () => { 49 + // add mouse movement 50 + canvas.addEventListener('mousemove', (event) => { 51 + mouse.set(event.clientX / window.innerWidth, event.clientY / window.innerHeight); 52 + }); 53 + 35 54 // jump on touch, click and space 36 - window.addEventListener('touchstart', (event) => { 55 + canvas.addEventListener('touchstart', (event) => { 37 56 jump(); 38 57 }); 39 - window.addEventListener('click', (event) => { 58 + canvas.addEventListener('click', (event) => { 40 59 jump(); 41 60 }); 42 61 window.addEventListener('keydown', (event) => { ··· 73 92 if (!(mesh instanceof THREE.SkinnedMesh)) return; 74 93 75 94 // add face and fur 76 - const material = new THREE.MeshStandardNodeMaterial({ 77 - roughness: 0.5 78 - }); 95 + const material = new THREE.MeshStandardNodeMaterial(); 79 96 material.colorNode = texture(canvasTexture); 80 97 81 98 material.bumpMap = canvasTexture; ··· 84 101 85 102 const furMeshes = []; 86 103 87 - let total = 16; 104 + let total = 12; 88 105 for (let i = 0; i < total; i++) { 89 106 const material = new THREE.MeshStandardNodeMaterial({ 90 107 transparent: true, 91 - opacity: 1, 92 - side: THREE.DoubleSide 108 + opacity: 1 93 109 }); 94 110 111 + // move vertices along normal to make each shell slightly bigger than the last 95 112 material.positionNode = positionGeometry 96 - .add(normalGeometry.mul(i * 0.007)) 113 + .add(normalGeometry.mul(i * 0.01)) 114 + // simulate some gravity 97 115 .add(vec3(0, -0.002, 0).mul(i)); 98 116 117 + const primaryColor = uniform(new THREE.Color(colors.primary)); 118 + const secondaryColor = uniform(new THREE.Color(colors.secondary)); 119 + 120 + primaryColors.push(primaryColor); 121 + secondaryColors.push(secondaryColor); 122 + 99 123 material.colorNode = Fn(() => { 100 - const t = texture(furTexture).toVar(); 124 + const t = texture(furTexture); 101 125 126 + // noise color 102 127 const c = mix( 103 - color(0xec4899), 104 - color(0xfb923c), 128 + color(primaryColor), 129 + color(secondaryColor), 105 130 mx_noise_float(positionGeometry.mul(10).mul(0.5).add(0.5)) 106 131 ); 132 + // const c = color(0xec4899); 107 133 108 - const s = select(t.g.lessThan(0.01).and(t.b.lessThan(0.01)), c, t.rgb); 134 + // how much fur is there 135 + const a = t.a; 109 136 110 - return s.mul( 137 + // if fur texture is black, use noise color 138 + // make lower layers darker 139 + const s = select(t.rgb.lengthSq().lessThan(0.00001), c, t).mul( 111 140 float(i / total) 112 - .add(t.a.mul(-1).add(1)) 141 + .add(a.mul(-1).add(1)) 113 142 .mul(0.8) 114 143 .add(0.2) 115 144 ); 116 - })(); 117 145 118 - material.alphaTestNode = float(0.5); 119 - 120 - material.opacityNode = Fn(() => { 121 - const t = texture(furTexture).toVar(); 122 - 123 - return select( 124 - mx_noise_float(positionGeometry.mul(float(200).add(t.a.mul(-100)))).greaterThan( 125 - float((i / total) * 2 - 1).add(t.a.mul(-2).add(2)) 146 + // make more parts of the upper layers transparent 147 + const opacity = select( 148 + mx_noise_float(positionGeometry.mul(float(200).add(a.mul(-100)))).greaterThan( 149 + float((i / total) * 2 - 1).add(a.mul(-2).add(2)) 126 150 ), 127 151 1.0, 128 152 0.0 129 153 ); 154 + 155 + return vec4(s.rgb, opacity); 130 156 })(); 131 157 158 + material.alphaTestNode = float(0.9); 159 + 132 160 const fur = mesh.clone(); 161 + fur.name = `fur-${i}`; 133 162 fur.material = material; 134 163 135 164 furMeshes.push(fur); ··· 152 181 }); 153 182 154 183 let total = 0; 184 + let ySpeed = 0; 155 185 156 - let ySpeed = 0; 186 + let lastColors = { 187 + primary: colors.primary.toLowerCase(), 188 + secondary: colors.secondary.toLowerCase() 189 + }; 157 190 158 191 useTask((dt) => { 159 192 total += dt; ··· 162 195 }); 163 196 164 197 if (canvasTexture) { 165 - updateFace(dt, 0, 0); 198 + updateFace(dt, (mouse.x - 0.5) * 2, -(mouse.y - 0.5) * 2); 166 199 canvasTexture.needsUpdate = true; 167 200 } 168 201 ··· 178 211 179 212 ySpeed -= 10 * dt; 180 213 } 214 + 215 + if ( 216 + lastColors.primary !== colors.primary.toLowerCase() || 217 + lastColors.secondary !== colors.secondary.toLowerCase() 218 + ) { 219 + updateBackground(colors.primary); 220 + 221 + primaryColors.forEach((primaryColor) => { 222 + primaryColor.value = new THREE.Color(colors.primary); 223 + }); 224 + 225 + secondaryColors.forEach((secondaryColor) => { 226 + secondaryColor.value = new THREE.Color(colors.secondary); 227 + }); 228 + } 181 229 }); 230 + 182 231 </script> 183 232 184 233 {#if creature}
+202
src/lib/Pane.svelte
··· 1 + <script lang="ts"> 2 + import { 3 + Checkbox, 4 + Pane, 5 + Slider, 6 + Textarea, 7 + Folder, 8 + Color, 9 + ThemeUtils, 10 + Button 11 + } from 'svelte-tweakpane-ui'; 12 + import { eyebrowLeft, eyebrowRight, furMouth, mouth } from './face/face'; 13 + import { colors } from './state.svelte'; 14 + 15 + const options = $state({ 16 + leftEyebrow: { 17 + x: 75 / 2, 18 + y: 40, 19 + width: 50, 20 + curve: 10, 21 + stroke: 10 22 + }, 23 + rightEyebrow: { 24 + x: -75 / 2, 25 + y: 40, 26 + width: 50, 27 + curve: 10, 28 + stroke: 10 29 + }, 30 + mouth: { 31 + x: 0, 32 + y: -130, 33 + stroke: 20, 34 + color: 0, 35 + width: 100, 36 + curve: -40 37 + } 38 + }); 39 + 40 + function change() { 41 + console.log(eyebrowLeft); 42 + eyebrowLeft?.options.set('curve', options.leftEyebrow.curve); 43 + eyebrowLeft?.options.set('stroke', options.leftEyebrow.stroke); 44 + eyebrowLeft?.options.set('width', options.leftEyebrow.width); 45 + eyebrowLeft?.options.set('x', options.leftEyebrow.x); 46 + eyebrowLeft?.options.set('y', options.leftEyebrow.y); 47 + 48 + eyebrowRight?.options.set('curve', options.rightEyebrow.curve); 49 + eyebrowRight?.options.set('stroke', options.rightEyebrow.stroke); 50 + eyebrowRight?.options.set('width', options.rightEyebrow.width); 51 + eyebrowRight?.options.set('x', options.rightEyebrow.x); 52 + eyebrowRight?.options.set('y', options.rightEyebrow.y); 53 + 54 + mouth?.options.set('curve', options.mouth.curve); 55 + mouth?.options.set('stroke', options.mouth.stroke); 56 + mouth?.options.set('width', options.mouth.width); 57 + mouth?.options.set('x', options.mouth.x); 58 + mouth?.options.set('y', options.mouth.y); 59 + 60 + furMouth?.options.set('curve', options.mouth.curve); 61 + furMouth?.options.set('stroke', options.mouth.stroke); 62 + furMouth?.options.set('width', options.mouth.width); 63 + furMouth?.options.set('x', options.mouth.x); 64 + furMouth?.options.set('y', options.mouth.y); 65 + } 66 + </script> 67 + 68 + <div 69 + class="pointer-events-auto absolute right-2 top-2 z-50 w-full max-w-64 rounded-lg border border-white/10 backdrop-blur-sm" 70 + > 71 + <Pane title="Settings" position="inline" theme={ThemeUtils.presets.translucent}> 72 + <Folder expanded={false} title="Left Eyebrow"> 73 + <Slider 74 + label="curve" 75 + bind:value={options.leftEyebrow.curve} 76 + min={-40} 77 + max={40} 78 + on:change={change} 79 + /> 80 + <Slider 81 + label="stroke" 82 + bind:value={options.leftEyebrow.stroke} 83 + min={1} 84 + max={10} 85 + on:change={change} 86 + /> 87 + <Slider 88 + label="width" 89 + bind:value={options.leftEyebrow.width} 90 + min={1} 91 + max={200} 92 + on:change={change} 93 + /> 94 + <Slider 95 + label="x" 96 + bind:value={options.leftEyebrow.x} 97 + min={-100} 98 + max={100} 99 + on:change={change} 100 + /> 101 + <Slider 102 + label="y" 103 + bind:value={options.leftEyebrow.y} 104 + min={-100} 105 + max={100} 106 + on:change={change} 107 + /> 108 + </Folder> 109 + <Folder expanded={false} title="Right Eyebrow"> 110 + <Slider 111 + label="curve" 112 + bind:value={options.rightEyebrow.curve} 113 + min={-40} 114 + max={40} 115 + on:change={change} 116 + /> 117 + <Slider 118 + label="stroke" 119 + bind:value={options.rightEyebrow.stroke} 120 + min={1} 121 + max={10} 122 + on:change={change} 123 + /> 124 + <Slider 125 + label="width" 126 + bind:value={options.rightEyebrow.width} 127 + min={1} 128 + max={200} 129 + on:change={change} 130 + /> 131 + <Slider 132 + label="x" 133 + bind:value={options.rightEyebrow.x} 134 + min={-100} 135 + max={100} 136 + on:change={change} 137 + /> 138 + <Slider 139 + label="y" 140 + bind:value={options.rightEyebrow.y} 141 + min={-100} 142 + max={100} 143 + on:change={change} 144 + /> 145 + </Folder> 146 + <Folder expanded={false} title="Mouth"> 147 + <Slider 148 + label="curve" 149 + bind:value={options.mouth.curve} 150 + min={-100} 151 + max={100} 152 + on:change={change} 153 + /> 154 + <Slider 155 + label="stroke" 156 + bind:value={options.mouth.stroke} 157 + min={1} 158 + max={120} 159 + on:change={change} 160 + /> 161 + <Slider 162 + label="width" 163 + bind:value={options.mouth.width} 164 + min={1} 165 + max={200} 166 + on:change={change} 167 + /> 168 + <Slider 169 + label="x" 170 + bind:value={options.mouth.x} 171 + min={-100} 172 + max={100} 173 + on:change={change} 174 + /> 175 + <Slider 176 + label="y" 177 + bind:value={options.mouth.y} 178 + min={-200} 179 + max={100} 180 + on:change={change} 181 + /> 182 + </Folder> 183 + 184 + <Folder expanded={false} title="Colors"> 185 + <Color 186 + label="Primary" 187 + bind:value={colors.primary} 188 + /> 189 + <Color 190 + label="Secondary" 191 + bind:value={colors.secondary} 192 + /> 193 + </Folder> 194 + 195 + <!-- <Button 196 + on:click={() => { 197 + console.log(JSON.stringify(options, null, 2)); 198 + }} 199 + title="Log" 200 + /> --> 201 + </Pane> 202 + </div>
+3 -1
src/lib/Scene.svelte
··· 13 13 14 14 onMount(() => { 15 15 renderer.toneMapping = ACESFilmicToneMapping; 16 - renderer.toneMappingExposure = 0.4; 16 + renderer.toneMappingExposure = 0.3; 17 17 18 18 stats.showPanel(0); 19 19 document.body.appendChild(stats.dom); ··· 44 44 <Creature /> 45 45 46 46 <Environment isBackground={false} url={'/tiny-creature/workshop.hdr'} /> 47 + 48 + <T.DirectionalLight position={[0, 10, 10]} intensity={1} />
+63
src/lib/face/curve.ts
··· 1 + import * as PIXI from 'pixi.js'; 2 + import { AnimatedProperties } from './math-helper'; 3 + 4 + export type CurveOptions = { 5 + scale: number; 6 + stroke: number; 7 + width: number; 8 + curve: number; 9 + 10 + color: PIXI.ColorSource; 11 + 12 + x: number; 13 + y: number; 14 + }; 15 + 16 + export default class Curve { 17 + graphics: PIXI.Graphics; 18 + 19 + color: PIXI.ColorSource = 0x515151; 20 + 21 + options: AnimatedProperties<Omit<CurveOptions, 'color'>>; 22 + 23 + constructor(opts: Partial<CurveOptions>) { 24 + this.options = new AnimatedProperties({ 25 + scale: opts?.scale ?? 1, 26 + stroke: opts?.stroke ?? 5, 27 + width: opts?.width ?? 50, 28 + curve: opts?.curve ?? 10, 29 + x: opts?.x ?? 0, 30 + y: opts?.y ?? 0 31 + }); 32 + this.graphics = new PIXI.Graphics(); 33 + this.color = opts?.color ?? 0x212121; 34 + 35 + this.draw(); 36 + } 37 + 38 + draw() { 39 + const width = this.options.get('width'); 40 + const curve = this.options.get('curve'); 41 + const scale = this.options.get('scale'); 42 + const stroke = this.options.get('stroke'); 43 + const x = this.options.get('x'); 44 + const y = this.options.get('y'); 45 + 46 + this.graphics.clear(); 47 + this.graphics 48 + .moveTo((-width / 2) * scale, 0) 49 + .quadraticCurveTo(0, curve * scale, (width / 2) * scale, 0, -10) 50 + .stroke({ 51 + color: this.color, 52 + width: stroke * scale, 53 + cap: 'round' 54 + }); 55 + 56 + this.graphics.position.set(x * scale, y * scale); 57 + } 58 + 59 + update(deltaTime: number) { 60 + const updated = this.options.update(deltaTime); 61 + if (updated) this.draw(); 62 + } 63 + }
+65 -68
src/lib/face/eye.ts
··· 1 1 import * as PIXI from "pixi.js"; 2 - import { AnimatedValue, degreesToRadians } from "./math-helper"; 2 + import { AnimatedProperty, degreesToRadians } from './math-helper'; 3 3 4 4 export type EyeOptions = { 5 5 container: PIXI.Container; ··· 31 31 }; 32 32 33 33 export default class Eye { 34 - eyeContainer: PIXI.Container; 34 + eyeContainer: PIXI.Container; 35 35 36 - container: PIXI.Container; 36 + container: PIXI.Container; 37 37 38 - base?: PIXI.Graphics; 39 - pupil?: PIXI.Graphics; 40 - highlight?: PIXI.Sprite; 38 + base?: PIXI.Graphics; 39 + pupil?: PIXI.Graphics; 40 + highlight?: PIXI.Sprite; 41 41 42 - mask?: PIXI.Graphics; 42 + mask?: PIXI.Graphics; 43 43 44 - // how long one blink takes, split into four parts [close animation, closed, open animation, open] 45 - blinkSpeed: [number, number, number, number] = [0.05, 0.1, 0.1, 1]; 44 + // how long one blink takes, split into four parts [close animation, closed, open animation, open] 45 + blinkSpeed: [number, number, number, number] = [0.05, 0.1, 0.1, 1]; 46 46 47 - private blinkTimer: number = 0; 48 - private blinkState: number = 3; 47 + private blinkTimer: number = 0; 48 + private blinkState: number = 3; 49 49 50 - _x: AnimatedValue; 51 - _y: AnimatedValue; 50 + _x: AnimatedProperty; 51 + _y: AnimatedProperty; 52 52 53 - dx: number = 0; 54 - dy: number = 0; 53 + dx: number = 0; 54 + dy: number = 0; 55 55 56 - size: number = 1; 56 + size: number = 1; 57 57 58 - targetX: number = 0; 59 - targetY: number = 0; 58 + targetX: number = 0; 59 + targetY: number = 0; 60 60 61 - constructor(opts: Partial<EyeOptions>) { 61 + constructor(opts: Partial<EyeOptions>) { 62 62 console.log('Eye', opts); 63 63 this.container = new PIXI.Container(); 64 64 ··· 75 75 this.blinkTimer = opts.blinkTimer; 76 76 } 77 77 78 - this._x = new AnimatedValue(); 79 - this._y = new AnimatedValue(); 78 + this._x = new AnimatedProperty(); 79 + this._y = new AnimatedProperty(); 80 80 81 81 this.container.addChild(this.eyeContainer); 82 82 ··· 114 114 // this.container.addChild(eyebrow); 115 115 } 116 116 117 - lookInDirection(direction: number) { 118 - const angle = degreesToRadians(direction); 117 + lookInDirection(direction: number) { 118 + const angle = degreesToRadians(direction); 119 119 120 - this.lookAt(Math.cos(angle), Math.sin(angle)); 121 - } 120 + this.lookAt(Math.cos(angle), Math.sin(angle)); 121 + } 122 122 123 - lookAt(x: number, y: number) { 124 - this._x.value = x * this.size * 0.3; 125 - this._y.value = y * this.size * 0.3; 126 - } 123 + lookAt(x: number, y: number) { 124 + this._x.set(x * this.size * 0.3); 125 + this._y.set(y * this.size * 0.3); 126 + } 127 127 128 - rotate(angle: number) { 129 - // get current angle 130 - let currentAngle = Math.atan2(this._y.value, this._x.value); 128 + rotate(angle: number) { 129 + // get current angle 130 + const currentAngle = Math.atan2(this._y.value, this._x.value); 131 131 132 - let moveAngle = degreesToRadians(angle); 132 + const moveAngle = degreesToRadians(angle); 133 133 134 - this.lookAt( 135 - Math.cos(currentAngle + moveAngle), 136 - Math.sin(currentAngle + moveAngle), 137 - ); 138 - } 134 + this.lookAt(Math.cos(currentAngle + moveAngle), Math.sin(currentAngle + moveAngle)); 135 + } 139 136 140 - update(deltaTime: number) { 141 - let updateX = this._x.update(deltaTime); 142 - let updateY = this._y.update(deltaTime); 143 - if (updateX || updateY) { 144 - this.pupil?.position.set(this._x.value, this._y.value); 145 - } 137 + update(deltaTime: number) { 138 + const updateX = this._x.update(deltaTime); 139 + const updateY = this._y.update(deltaTime); 140 + if (updateX || updateY) { 141 + this.pupil?.position.set(this._x.value, this._y.value); 142 + } 146 143 147 - if (this.mask) { 148 - // Blink animation 149 - this.blinkTimer += deltaTime; 144 + if (this.mask) { 145 + // Blink animation 146 + this.blinkTimer += deltaTime; 150 147 151 - if (this.blinkTimer >= this.blinkSpeed[this.blinkState]) { 152 - this.blinkTimer = 0; 153 - this.blinkState = (this.blinkState + 1) % 4; 154 - } 148 + if (this.blinkTimer >= this.blinkSpeed[this.blinkState]) { 149 + this.blinkTimer = 0; 150 + this.blinkState = (this.blinkState + 1) % 4; 151 + } 155 152 156 - let scale: number; 157 - switch (this.blinkState) { 158 - case 0: // Closing 159 - scale = 1 - this.blinkTimer / this.blinkSpeed[0]; 160 - break; 161 - case 1: // Closed 162 - scale = 0; 163 - break; 164 - case 2: // Opening 165 - scale = this.blinkTimer / this.blinkSpeed[2]; 166 - break; 167 - default: // Open 168 - scale = 1; 169 - } 170 - this.mask.scale.y = scale; 171 - } 172 - } 153 + let scale: number; 154 + switch (this.blinkState) { 155 + case 0: // Closing 156 + scale = 1 - this.blinkTimer / this.blinkSpeed[0]; 157 + break; 158 + case 1: // Closed 159 + scale = 0; 160 + break; 161 + case 2: // Opening 162 + scale = this.blinkTimer / this.blinkSpeed[2]; 163 + break; 164 + default: // Open 165 + scale = 1; 166 + } 167 + this.mask.scale.y = scale; 168 + } 169 + } 173 170 }
+51 -17
src/lib/face/face.ts
··· 1 1 import { Application, Container, Graphics, type Renderer } from 'pixi.js'; 2 2 import Eye from './eye'; 3 - import Mouth from './mouth'; 3 + import Curve from './curve'; 4 4 5 5 let leftEye: Eye; 6 6 let rightEye: Eye; 7 7 8 - let mouth: Mouth; 8 + let mouth: Curve; 9 + let furMouth: Curve; 10 + 11 + let eyebrowLeft: Curve; 12 + let eyebrowRight: Curve; 13 + 14 + let background: Graphics; 9 15 10 16 const mouthSettings = { 11 17 x: 0, ··· 16 22 curve: -40 17 23 }; 18 24 25 + const size = { 26 + width: 512, 27 + height: 512 28 + }; 29 + 19 30 export async function createFaceTexture({ 20 - width = 512, 21 - height = 512 31 + width = size.width, 32 + height = size.height 22 33 }: { 23 34 width?: number; 24 35 height?: number; 25 36 }): Promise<{ app: Application<Renderer> }> { 37 + size.width = width; 38 + size.height = height; 39 + 26 40 const xScale = 0.4; 27 41 const yScale = 1.5; 28 42 const app = new Application(); ··· 30 44 await app.init({ 31 45 width, 32 46 height, 33 - background: '#be185d' 47 + background: 'black' 34 48 }); 35 49 50 + background = new Graphics(); 51 + background.rect(0, 0, width, height); 52 + background.fill({ color: '#be185d' }); 53 + background.alpha = 0.7; 54 + app.stage.addChild(background); 55 + 36 56 const container = new Container(); 37 57 container.x = width * 0.5; 38 58 container.y = height * 0.625; ··· 54 74 container.addChild(rightEye.container); 55 75 container.addChild(leftEye.container); 56 76 57 - mouth = new Mouth(mouthSettings); 58 - container.addChild(mouth.container); 77 + mouth = new Curve(mouthSettings); 78 + container.addChild(mouth.graphics); 59 79 60 80 return { 61 81 app ··· 149 169 eyeMaskRight.fill({ color: '#ffffff' }); 150 170 container.addChild(eyeMaskRight); 151 171 152 - const eyebrowLeft = new Mouth({ 172 + eyebrowLeft = new Curve({ 153 173 x: 75 / 2, 154 174 y: 40, 155 175 width: 50, 156 176 curve: 10, 157 177 stroke: 10 158 178 }); 159 - eyebrowLeft.container.alpha = 0.5; 160 - container.addChild(eyebrowLeft.container); 179 + eyebrowLeft.graphics.alpha = 0.5; 180 + container.addChild(eyebrowLeft.graphics); 161 181 162 - const eyebrowRight = new Mouth({ 182 + eyebrowRight = new Curve({ 163 183 x: -75 / 2, 164 184 y: 40, 165 185 width: 50, 166 186 curve: 10, 167 187 stroke: 10 168 188 }); 169 - eyebrowRight.container.alpha = 0.5; 170 - container.addChild(eyebrowRight.container); 189 + eyebrowRight.graphics.alpha = 0.5; 190 + container.addChild(eyebrowRight.graphics); 171 191 172 - const mouth3 = new Mouth({ 192 + furMouth = new Curve({ 173 193 ...mouthSettings, 174 194 scale: 0.5 175 195 }); 176 - // mouth3.container.alpha = 0.5; 177 - mouth3.container.blendMode = 'erase'; 178 - container.addChild(mouth3.container); 196 + 197 + furMouth.graphics.blendMode = 'erase'; 198 + container.addChild(furMouth.graphics); 179 199 180 200 return { 181 201 app 182 202 }; 183 203 } 184 204 205 + export { leftEye, rightEye, mouth, eyebrowLeft, eyebrowRight, furMouth }; 206 + 207 + export function updateBackground(color: string) { 208 + background.clear(); 209 + background.rect(0, 0, size.width, size.height); 210 + background.fill({ color }); 211 + } 212 + 185 213 export function updateFace(dt: number, x: number, y: number) { 186 214 leftEye?.lookAt(x, y); 187 215 rightEye?.lookAt(x, y); 216 + 217 + eyebrowLeft?.update(dt); 218 + eyebrowRight?.update(dt); 219 + 220 + mouth?.update(dt); 221 + furMouth?.update(dt); 188 222 189 223 leftEye?.update(dt); 190 224 rightEye?.update(dt);
+88 -62
src/lib/face/math-helper.ts
··· 11 11 * from the source to the target. 12 12 * @returns {number} The value in between source and target, depending on the amount. 13 13 */ 14 - export function smoothStep( 15 - source: number, 16 - target: number, 17 - amount: number, 18 - ): number { 19 - return source * (1 - amount) + target * amount; 14 + export function smoothStep(source: number, target: number, amount: number): number { 15 + return source * (1 - amount) + target * amount; 20 16 } 21 17 22 18 /** ··· 43 39 * @returns {number} The value in between source and target, adjusted for framerate. 44 40 */ 45 41 export function framerateIndependentSmoothStep( 46 - source: number, 47 - target: number, 48 - rate: number, 49 - frameDelta: number, 50 - targetFps = 60, 42 + source: number, 43 + target: number, 44 + rate: number, 45 + frameDelta: number, 46 + targetFps = 60 51 47 ): number { 52 - if (typeof frameDelta === "undefined") { 53 - return smoothStep(source, target, rate); 54 - } 48 + if (typeof frameDelta === 'undefined') { 49 + return smoothStep(source, target, rate); 50 + } 55 51 56 - const relativeDelta = frameDelta / (1 / targetFps); 57 - const smoothing = 1 - rate; 58 - return smoothStep(source, target, 1 - Math.pow(smoothing, relativeDelta)); 52 + const relativeDelta = frameDelta / (1 / targetFps); 53 + const smoothing = 1 - rate; 54 + return smoothStep(source, target, 1 - Math.pow(smoothing, relativeDelta)); 59 55 } 60 56 61 57 /** ··· 65 61 * @returns {number} The angle in radians. 66 62 */ 67 63 export function degreesToRadians(degrees: number): number { 68 - return degrees * (Math.PI / 180); 64 + return degrees * (Math.PI / 180); 69 65 } 70 66 71 67 /** ··· 75 71 * @returns {number} The angle in degrees. 76 72 */ 77 73 export function radiansToDegrees(radians: number): number { 78 - return radians * (180 / Math.PI); 74 + return radians * (180 / Math.PI); 79 75 } 80 76 81 77 /** ··· 91 87 * @returns {number} The number, but kept within the specified range. 92 88 */ 93 89 export function clamp(value: number, min: number, max: number): number { 94 - return Math.max(min, Math.min(max, value)); 90 + return Math.max(min, Math.min(max, value)); 95 91 } 96 92 97 93 /** ··· 107 103 * @returns {number} The wrapped number within the range. 108 104 */ 109 105 export function wrap(value: number, min: number, max: number): number { 110 - return ((value - min) % (max - min)) + min; 106 + return ((value - min) % (max - min)) + min; 111 107 } 112 108 113 109 /** ··· 125 121 * @param {number} outMax - The end of the new range. 126 122 * @returns {number} The number converted to the new range. 127 123 */ 128 - export function map( 129 - value: number, 130 - inMin: number, 131 - inMax: number, 132 - outMin: number, 133 - outMax: number, 134 - ): number { 135 - return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; 124 + export function map(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number { 125 + return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; 136 126 } 137 127 138 - const epsilon = 0.001; 128 + const epsilon = 0.00001; 139 129 140 - export class AnimatedValue { 141 - _value: number; 142 - _target: number; 130 + export class AnimatedProperty { 131 + _value: number; 132 + _target: number; 143 133 144 - rate: number; 134 + rate: number; 145 135 146 - needsUpdate: boolean = false; 136 + needsUpdate: boolean = false; 147 137 148 - constructor(opts?: { value: number; rate: number }) { 149 - this._value = opts?.value ?? 0; 150 - this._target = opts?.value ?? 0; 151 - this.rate = opts?.rate ?? 0.1; 152 - } 138 + constructor(value: number = 0, rate: number = 0.1) { 139 + this._value = value; 140 + this._target = value; 141 + this.rate = rate; 142 + } 153 143 154 - get value() { 155 - return this._value; 156 - } 144 + setInstantly(value: number) { 145 + this._value = value; 146 + this.needsUpdate = true; 147 + } 157 148 158 - set value(value: number) { 159 - this._target = value; 160 - this.needsUpdate = true; 161 - } 149 + get value() { 150 + return this._value; 151 + } 152 + 153 + set target(value: number) { 154 + this._target = value; 155 + this.needsUpdate = true; 156 + } 157 + 158 + set(target: number) { 159 + this._target = target; 160 + this.needsUpdate = true; 161 + } 162 + 163 + get(): number { 164 + return this._value; 165 + } 166 + 167 + update(dt: number) { 168 + if (!this.needsUpdate) return false; 169 + 170 + this._value = framerateIndependentSmoothStep(this._value, this._target, this.rate, dt); 171 + this.needsUpdate = Math.abs(this._value - this._target) > epsilon; 172 + 173 + return true; 174 + } 175 + } 176 + 177 + export class AnimatedProperties<T extends Record<string, number>> { 178 + private readonly properties: { [K in keyof T]: AnimatedProperty }; 179 + 180 + constructor(initialValues: T, defaultRate: number = 0.1) { 181 + this.properties = {} as { [K in keyof T]: AnimatedProperty }; 182 + for (const key in initialValues) { 183 + this.properties[key] = new AnimatedProperty(initialValues[key], defaultRate); 184 + } 185 + } 162 186 163 - update(dt: number) { 164 - if (!this.needsUpdate) return false; 187 + // Get the property directly via dot notation 188 + get<K extends keyof T>(key: K): number { 189 + return this.properties[key].get(); 190 + } 165 191 166 - this._value = framerateIndependentSmoothStep( 167 - this._value, 168 - this._target, 169 - this.rate, 170 - dt, 171 - ); 192 + set<K extends keyof T>(key: K, value: number) { 193 + this.properties[key].set(value); 194 + } 172 195 173 - if (Math.abs(this._value - this._target) > epsilon) { 174 - this.needsUpdate = true; 175 - } else { 176 - this.needsUpdate = false; 177 - } 178 - return true; 179 - } 196 + // Update all animated values 197 + update(dt: number): boolean { 198 + let anyUpdated = false; 199 + for (const key in this.properties) { 200 + if (this.properties[key].update(dt)) { 201 + anyUpdated = true; 202 + } 203 + } 204 + return anyUpdated; 205 + } 180 206 }
-54
src/lib/face/mouth.ts
··· 1 - import * as PIXI from 'pixi.js'; 2 - 3 - export type EyeOptions = { 4 - container: PIXI.Container; 5 - 6 - scale: number; 7 - 8 - stroke: number; 9 - 10 - width: number; 11 - 12 - curve: number; 13 - 14 - color: PIXI.ColorSource; 15 - 16 - x: number; 17 - y: number; 18 - }; 19 - 20 - export default class Mouth { 21 - container: PIXI.Container; 22 - 23 - scale: number = 1; 24 - 25 - x: number = 0; 26 - y: number = 0; 27 - 28 - constructor(opts: Partial<EyeOptions>) { 29 - this.container = new PIXI.Container(); 30 - 31 - this.scale = opts?.scale ?? 1; 32 - 33 - this.x = opts?.x ?? 0; 34 - this.y = opts?.y ?? 0; 35 - 36 - if (opts.container) opts.container.addChild(this.container); 37 - 38 - const width = opts?.width ?? 50; 39 - const curve = opts?.curve ?? 10; 40 - 41 - const eyebrow = new PIXI.Graphics() 42 - .moveTo((-width / 2) * this.scale, 0) 43 - .quadraticCurveTo(0, curve * this.scale, (width / 2) * this.scale, 0) 44 - .stroke({ 45 - color: opts?.color ?? 0x101010, 46 - width: (opts?.stroke ?? 5) * this.scale, 47 - cap: 'round' 48 - }); 49 - 50 - eyebrow.position.set(this.x * this.scale, this.y * this.scale); 51 - 52 - this.container.addChild(eyebrow); 53 - } 54 - }
+4
src/lib/state.svelte.ts
··· 1 + export const colors = $state({ 2 + primary: '#ec4899', 3 + secondary: '#fb923c' 4 + });
+5 -3
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { Canvas } from '@threlte/core'; 3 3 import Scene from '$lib/Scene.svelte'; 4 - import { Studio } from '@threlte/studio'; 5 4 import { WebGPURenderer } from 'three/webgpu' 6 - import { PerfMonitor } from '@threlte/extras'; 5 + import Pane from '$lib/Pane.svelte'; 7 6 8 7 </script> 9 8 10 9 <div class="h-screen w-screen bg-gradient-to-t from-stone-950 to-stone-900/50"> 11 - <Canvas createRenderer={(canvas) => { 10 + <Canvas createRenderer={(canvas) => { 12 11 return new WebGPURenderer({ 13 12 canvas, 14 13 antialias: true, ··· 20 19 <!-- </Studio> --> 21 20 </Canvas> 22 21 </div> 22 + 23 + 24 + <Pane />