جسيمات GPGPU باستخدام TSL وWebGPU
في هذا الدرس، سنقوم بإنشاء مئات الآلاف من الجسيمات العائمة لإنشاء نماذج ثلاثية الأبعاد ونصوص ثلاثية الأبعاد باستخدام Three Shading Language (TSL) وWebGPU.
بدلاً من استخدام الوجوه، نستخدم الكثير من الجسيمات، مما يسمح لنا بالانتقال بسلاسة بين النماذج المختلفة.
نموذج ثلاثي الأبعاد لثعلب وكتاب ونص ثلاثي الأبعاد مكوّن باستخدام جسيمات GPGPU! 🚀
نظام جسيمات GPGPU
قبل أن نغوص في الكود، دعونا نفهم ما هو GPGPU وكيف يمكن استخدامه في Three.js.
ما هو GPGPU؟
GPGPU (الحوسبة العامة باستخدام وحدات معالجة الرسوميات) هي تقنية تستفيد من القدرة على المعالجة المتوازية لـ GPUs للقيام بحسابات عادة ما تتولاها وحدة المعالجة المركزية (CPU).
في Three.js، يُستخدم GPGPU عادةً للمحاكاة في الوقت الفعلي، وأنظمة الجسيمات، والفيزياء عن طريق تخزين وتحديث البيانات في المواد الخام بدلاً من الاعتماد على حسابات وحدة المعالجة المركزية.
تسمح هذه التقنية للمظليلين بامتلاك ذاكرة وقدرات حساب، مما يمكنهم من أداء حسابات معقدة وتخزين النتائج في المواد الخام دون الحاجة إلى تدخل وحدة المعالجة المركزية.
يسمح ذلك بحسابات واسعة النطاق وفعالة للغاية مباشرة على وحدة معالجة الرسوميات (GPU).
بفضل TSL، أصبحت عملية إنشاء محاكاة GPGPU أسهل وأكثر سهولة. مع دمج عقد التخزين و المخزن مع وظائف الحساب، يمكننا إنشاء محاكاة معقدة بكمية قليلة من الكود.
إليكم بعض الأفكار عن المشاريع التي يمكن استخدام GPGPU فيها:
- أنظمة الجسيمات
- محاكاة السوائل
- محاكاة الفيزياء
- محاكاة الطيور
- معالجة الصور
حان الوقت للانتقال من النظرية إلى الممارسة! لنقم بإنشاء نظام جسيمات GPGPU باستخدام TSL وWebGPU.
نظام الجسيمات
عبوة البداية هي قالب جاهز لـ WebGPU بناءً على تنفيذ درس WebGPU/TSL.
لنقم باستبدال المجسم الوردي بمكوّن جديد يسمى GPGPUParticles
. أنشئ ملفًا جديدًا باسم GPGPUParticles.jsx
في مجلد src/components
وأضف الكود التالي:
import { extend } from "@react-three/fiber"; import { useMemo } from "react"; import { color, uniform } from "three/tsl"; import { AdditiveBlending, SpriteNodeMaterial } from "three/webgpu"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const { nodes, uniforms } = useMemo(() => { // uniforms const uniforms = { color: uniform(color("white")), }; return { uniforms, nodes: { colorNode: uniforms.color, }, }; }, []); return ( <> <sprite count={nbParticles}> <spriteNodeMaterial {...nodes} transparent depthWrite={false} blending={AdditiveBlending} /> </sprite> </> ); }; extend({ SpriteNodeMaterial });
لا شيء جديد هنا، نحن نقوم بإنشاء مكوّن GPGPUParticles
يستخدم Sprite مع SpriteNodeMaterial
لعرض الجسيمات.
الفائدة من استخدام Sprite
بدلاً من InstancedMesh
هي أنه أخف ويأتي مع تأثير لوحة الإعلانات بشكل افتراضي.
لنقم بإضافة مكوّن GPGPUParticles
إلى مكوّن Experience
:
import { OrbitControls } from "@react-three/drei"; import { GPGPUParticles } from "./GPGPUParticles"; export const Experience = () => { return ( <> {/* <Environment preset="warehouse" /> */} <OrbitControls /> <GPGPUParticles /> {/* <mesh> <boxGeometry /> <meshStandardMaterial color="hotpink" /> </mesh> */} </> ); };
يمكننا التخلص من مكوّنات المجسم و البيئة.
يمكننا رؤية مربع في منتصف الشاشة، هذه هي جسيمات الكتابة البيضاء. جميعها في نفس الموضع.
حان الوقت لإعداد نظام الجسيمات!
المخزن / التخزين / المصفوفة المثيلة
لإجراء محاكاة GPGPU لدينا، نحتاج إلى أن تذكر جزيئاتنا الموضع والسرعة والعمر واللون دون استخدام وحدة المعالجة المركزية.
هناك بعض الأشياء التي لن تتطلب منا تخزين البيانات، حيث يمكننا حساب اللون بناءً على العمر مع إضافة uniforms، ويمكننا توليد السرعة عشوائيًا باستخدام قيمة seed ثابتة.
ولكن بالنسبة للـ الموضع، بما أن الموضع الهدف قد يتطور، نحتاج إلى تخزينه في buffer. ينطبق الأمر نفسه على العمر حيث نريد معالجة دورة حياة الجزيئات في الوحده معالجة الرسوميات GPU.
لتخزين البيانات في الوحده معالجة الرسوميات GPU، يمكننا استخدام storage node. يسمح لنا بتخزين كميات كبيرة من البيانات المنظمة التي يمكن تحديثها على الوحده معالجة الرسوميات GPU.
لاستخدامه بأقل جهد في الكود، سنستخدم وظيفة InstancedArray TSL بالاعتماد على storage node.
لم يتم توثيق هذا الجزء من Three.js nodes بعد، من خلال الغوص في الأمثلة وكود المصدر يمكننا فهم كيفية عمله.
لنقم بتحضير المخزن في useMemo
حيث نضع الـ shader nodes:
// ... import { instancedArray } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const { nodes, uniforms } = useMemo(() => { // uniforms const uniforms = { color: uniform(color("white")), }; // buffers const spawnPositionsBuffer = instancedArray(nbParticles, "vec3"); const offsetPositionsBuffer = instancedArray(nbParticles, "vec3"); const agesBuffer = instancedArray(nbParticles, "float"); return { uniforms, nodes: { colorNode: uniforms.color, }, }; }, []); // ... }; // ...
instancedArray
هي وظيفة TSL تقوم بإنشاء buffer بالحجم والنوع المحددين.
سيبدو الكود نفسه باستخدام storage node هكذا:
import { storage } from "three/tsl"; import { StorageInstancedBufferAttribute } from "three/webgpu"; const spawnPositionsBuffer = storage( new StorageInstancedBufferAttribute(nbParticles, 3), "vec3", nbParticles );
مع هذه المخازن، يمكننا تخزين الموضع والعمر لكل جزيء وتحديثها في الوحده معالجة الرسوميات GPU.
للوصول إلى البيانات في المخازن، يمكننا استخدام .element(index)
للحصول على القيمة في الموقع المحدد.
في حالتنا، سنستخدم instancedIndex
لكل جزيء للوصول إلى البيانات في المخازن:
// ... import { instanceIndex } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const { nodes, uniforms } = useMemo(() => { // ... // buffers const spawnPositionsBuffer = instancedArray(nbParticles, "vec3"); const offsetPositionsBuffer = instancedArray(nbParticles, "vec3"); const agesBuffer = instancedArray(nbParticles, "float"); const spawnPosition = spawnPositionsBuffer.element(instanceIndex); const offsetPosition = offsetPositionsBuffer.element(instanceIndex); const age = agesBuffer.element(instanceIndex); return { uniforms, nodes: { colorNode: uniforms.color, }, }; }, []); // ... }; // ...
instanceIndex
هي وظيفة TSL مدمجة تُرجع مؤشر المثال الحالي قيد المعالجة.
هذا يتيح لنا الوصول إلى البيانات في المخازن لكل جزيء.
لن نحتاج إليها في هذا المشروع، ولكن بفضل القدرة على الوصول إلى بيانات المثال الآخر، يمكننا إنشاء تفاعلات معقدة بين الجزيئات. على سبيل المثال، يمكننا إنشاء سرب من الطيور يتبع بعضها البعض.
الحساب الأولي
لإعداد موضع وعمر الجسيمات، نحتاج إلى إنشاء وظيفة compute سيتم تنفيذها على GPU في بداية المحاكاة.
لإنشاء دالة compute باستخدام TSL، نحتاج إلى استخدام العقدة Fn
لاستدعائها، واستخدام دالة compute
التي تعيدها مع عدد الجسيمات:
// ... import { Fn } from "three/src/nodes/TSL.js"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const { nodes, uniforms } = useMemo(() => { // ... const spawnPosition = spawnPositionsBuffer.element(instanceIndex); const offsetPosition = offsetPositionsBuffer.element(instanceIndex); const age = agesBuffer.element(instanceIndex); // init Fn const lifetime = randValue({ min: 0.1, max: 6, seed: 13 }); const computeInit = Fn(() => { spawnPosition.assign( vec3( randValue({ min: -3, max: 3, seed: 0 }), randValue({ min: -3, max: 3, seed: 1 }), randValue({ min: -3, max: 3, seed: 2 }) ) ); offsetPosition.assign(0); age.assign(randValue({ min: 0, max: lifetime, seed: 11 })); })().compute(nbParticles); // ... }, []); // ... }; // ...
نقوم بإنشاء دالة computeInit
التي تُعين المخازن المؤقتة لدينا بقيم عشوائية.
دالة randValue
غير موجودة، نحتاج إلى إنشائها بأنفسنا.
الدوال المتاحة لدينا هي:
hash(seed)
: لتوليد قيمة عشوائية بناءً على المفتاح تكون بين 0 و 1.range(min, max)
: لتوليد قيمة عشوائية بين الحد الأدنى والحد الأقصى.
لمزيد من المعلومات يمكنك زيارة Three.js Shading Language Wiki.
لكن دالة range
تُعرّف سمة وتخزن قيمتها. ليس هذا ما نريده.
دعونا ننشئ دالة randValue
التي ستُعيد قيمة عشوائية بين الحد الأدنى والحد الأقصى بناءً على المفتاح:
import { hash } from "three/tsl"; const randValue = /*#__PURE__*/ Fn(({ min, max, seed = 42 }) => { return hash(instanceIndex.add(seed)).mul(max.sub(min)).add(min); }); export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... }; // ...
دالة randValue
تأخذ قيم min
، max
، وseed
وتعيد قيمة عشوائية بين الحد الأدنى والحد الأقصى بناءً على الـ seed.
/*#__PURE__*/
هو تعليق يُستخدم لعملية tree-shaking. يُخبر المُجمّع بإزالة الدالة إذا لم تُستخدم. المزيد من التفاصيل هنا.
الآن، نحتاج إلى استدعاء دالة computeInit
. هذا هو دور المُعالج الرسومي. دعونا نستوردها باستخدام useThree
ونستدعيها مباشرة بعد إعلانها:
// ... import { useThree } from "@react-three/fiber"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const gl = useThree((state) => state.gl); const { nodes, uniforms } = useMemo(() => { // ... const computeInit = Fn(() => { // ... })().compute(nbParticles); gl.computeAsync(computeInit); // ... }, []); // ... }; // ...
لكي نتمكن من تصورها، نحتاج إلى تغيير positionNode
في SpriteNodeMaterial
لاستخدام مخازن spawnPosition
و offsetPosition
.
// ... export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms } = useMemo(() => { // ... return { uniforms, nodes: { positionNode: spawnPosition.add(offsetPosition), colorNode: uniforms.color, }, }; }, []); // ... }; // ...
نقوم بتعيين positionNode
لمجموع متجهات spawnPosition
و offsetPosition
.
هل يعمل؟ دعونا نتحقق من ذلك!
يوب! كل شيء أبيض! ⬜️
تكبير قليلاً؟
حسناً، يمكننا رؤية الجسيمات، إنها فقط كبيرة جدًا وقامت بتلوين الشاشة بأكملها! 😮💨
دعونا نصلح ذلك بتعيين scaleNode
بقيمة عشوائية:
// ... import { range } from "three/tsl"; // ... export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms } = useMemo(() => { // ... const scale = vec3(range(0.001, 0.01)); return { uniforms, nodes: { positionNode: spawnPosition.add(offsetPosition), colorNode: uniforms.color, scaleNode: scale, }, }; }, []); return ( <> <sprite count={nbParticles}> <spriteNodeMaterial {...nodes} transparent depthWrite={false} blending={AdditiveBlending} /> </sprite> </> ); }; // ...
في هذا السيناريو، يمكننا استخدام دالة range
لتوليد قيمة عشوائية بين 0.001
و 0.01
.
ممتاز، لدينا الآن جسيمات بأحجام وأماكن مختلفة! 🎉
إنها ثابتة قليلاً، نحتاج إلى إضافة بعض الحركة لها.
تحديث عملية الحساب
كما فعلنا مع دالة حساب التهيئة لنقم بإنشاء دالة تحديث الحساب التي سيتم تنفيذها في كل إطار.
في هذه الدالة، سنقوم بتحديث الموضع و العمر للجسيمات:
// ... import { deltaTime, If } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms } = useMemo(() => { // ... const instanceSpeed = randValue({ min: 0.01, max: 0.05, seed: 12 }); // update Fn const computeUpdate = Fn(() => { age.addAssign(deltaTime); If(age.greaterThan(lifetime), () => { age.assign(0); offsetPosition.assign(0); }); offsetPosition.addAssign(vec3(instanceSpeed)); })().compute(nbParticles); // ... }, []); // ... }; // ...
نقوم بتوليد سرعة عشوائية لكل جسيم. كن حذرًا في استخدام بذور فريدة بين المكالمات المختلفة لـ randValue
. (لا تتردد في استخدام حل أكثر أناقة لهذا، مثل عداد.)
ثم نقوم بإنشاء دالة computeUpdate
التي ستقوم بتحديث العمر و offsetPosition للجسيمات.
دالة deltaTime
هي دالة مضمنة في TSL تقوم بإرجاع الوقت المنقضي منذ آخر إطار.
دالة If
هي دالة مضمنة في TSL تسمح لنا بإنشاء عبارات شرطية في الـ shader.
تأخذ شرطًا ووظيفة للتنفيذ إذا كان الشرط صحيحًا.
في حالتنا، نتحقق مما إذا كان العمر أكبر من العمر الافتراضي. إذا كان الأمر كذلك، نعيد تعيين العمر و offsetPosition إلى 0
.
الآن نحتاج إلى تنفيذ هذه الدالة في كل إطار:
// ... import { useFrame } from "@react-three/fiber"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms, computeUpdate } = useMemo(() => { // ... return { uniforms, computeUpdate, // ... }; }, []); useFrame(() => { gl.compute(computeUpdate); }); // ... }; // ...
نجعل طريقة computeUpdate
متاحة خارج الـ useMemo
وندعوها في طريقة useFrame
.
الجسيمات تتحرك الآن وتعيد تكوين نفسها عندما تنتهي فترة عمرها! 🎉
تشكيل الجسيمات
قبل أن نبدأ في عرض النماذج ثلاثية الأبعاد باستخدام هذه الجسيمات، دعونا نقوم بضبط مظهر الجسيمات.
دعونا نجعلها دائرية:
// ... import { length, smoothstep, uv } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms, computeUpdate } = useMemo(() => { // ... // تحويل الجسيمات إلى دائرة const dist = length(uv().sub(0.5)); const circle = smoothstep(0.5, 0.49, dist); const finalColor = uniforms.color.mul(circle); return { uniforms, computeUpdate, nodes: { positionNode: spawnPosition.add(offsetPosition), colorNode: finalColor, scaleNode: scale, }, }; }, []); // ... }; extend({ SpriteNodeMaterial });
نستخدم دالة uv
للحصول على إحداثيات UV للجسيمات وحساب المسافة من مركز الجسيم.
ثم نستخدم دالة smoothstep
لإنشاء انتقال سلس بين المركز وحافة الجسيم.
أخيراً، نضرب اللون في قيمة الدائرة لجعلها دائرية.
دعونا نقوم بتقليل حجمها بناءً على عمرها. سوف يمنعها ذلك من الاختفاء بشكل سحري عندما تعيد الظهور.
// ... import { saturate } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms, computeUpdate } = useMemo(() => { // ... const scale = vec3(range(0.001, 0.01)); const particleLifetimeProgress = saturate(age.div(lifetime)); // ... return { uniforms, computeUpdate, nodes: { // ... scaleNode: scale.mul(smoothstep(1, 0, particleLifetimeProgress)), }, }; }, []); // ... }; // ...
نستخدم دالة saturate
لتحديد القيمة بين 0
و 1
. ثم نستخدم دالة smoothstep
لإنشاء انتقال سلس بين 1
و 0
.
لرؤية التأثير بشكل صحيح مع عدد كبير من الجسيمات، دعونا نغير القيمة الافتراضية nbParticles
إلى 500000
:
// ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... }; // ...
ينبغي استخدام قيمة أقل إذا لم يكن لديك دعم WebGPU أو جهاز منخفض الأداء.
هيا بنا! أصبحت الجسيمات الآن دائرية ويتم تقليل حجمها بناءً على عمرها! 🎉
النماذج
حان الوقت للانتقال من هذه الفوضى إلى عرض نماذج ثلاثية الأبعاد!
سنستخدم نموذج الثعلب بواسطة madtrollstudio CC-BY.
وأيضاً نموذج الكتاب المفتوح بواسطة Quaternius CC-BY.
كلاهما في مجلد public/models
.
دعونا نقوم بتحميلها باستخدام useGLTF
hook من @react-three/drei
وإضافة بعض التحكمات للتبديل بين النموذجين.
// ... import { useGLTF } from "@react-three/drei"; import { useControls } from "leva"; export const GPGPUParticles = ({ nbParticles = 500000 }) => { const { scene: foxScene } = useGLTF("/models/Fox.glb"); const { scene: bookScene } = useGLTF("/models/Open Book.glb"); const { curGeometry } = useControls({ curGeometry: { options: ["Fox", "Book"], value: "Fox", }, }); // ... }; // ...
حان الوقت لتحليل هندسة النماذج وتحويلها إلى صيغة يمكننا استخدامها مع الجسيمات لدينا.
تحليل الشكل الهندسي
الآن نحتاج إلى استعراض النموذج الحالي واستخراج مواضع القمم.
دعنا نقوم بذلك في useMemo
عندما يتغير curGeometry
ويتم تحميل النماذج:
// ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... const geometries = useMemo(() => { const geometries = []; const sceneToTraverse = { Book: bookScene, Fox: foxScene, }[curGeometry]; sceneToTraverse.traverse((child) => { if (child.isMesh) { geometries.push(child.geometry); } }); return geometries; }, [curGeometry, bookScene, foxScene]); // ... }; // ...
قمنا بإنشاء مصفوفة geometries
لتخزين أشكال النموذج الحالي. نستعرض النموذج وندفع الأشكال إلى المصفوفة.
للتوضيح:
const sceneToTraverse = { Book: bookScene, Fox: foxScene, }[curGeometry];
هي طريقة أكثر أناقة للكتابة:
let sceneToTraverse = null; if (curGeometry === "Book") { sceneToTraverse = bookScene; } else if (curGeometry === "Fox") { sceneToTraverse = foxScene; }
لا عيب في ذلك، فقد كانت طريقتي في البداية، ولكن يمكن تحسينها!
الآن حصلنا على الأشكال، نحتاج إلى استخراج مواضع القمم وتخزينها في مكان ما.
تخزين البيانات في نسيج
تنويه قصير: في البداية كنت سأخزن مواقع الرؤوس في
<instancedBufferAttribute />
لكن لم أتمكن من العثور على طريقة لجعلها تعمل باستخدام sprites.نظرًا لأنه ليس لدينا وصول إلى الهندسة قبل تجميع المادة، فإن attribute غير متاح في الظلّ.
الخبر السار هو أننا سنتعلم كيفية تخزين البيانات في نسيج بدلاً من ذلك! يتيح لنا ذلك تخزين البيانات بتنسيق مصفوفة ثنائية الأبعاد والوصول إليها بكفاءة في الظلّ.
نسيجنا سيحتوي على بيانات لكل جسيم، ولكل جسيم، سنخزن الموقع لرأس عشوائية من النموذج.
لتحديد حجم النسيج، حيث سيكون نسيج ثنائي الأبعاد، نحتاج إلى حساب الجذر التربيعي لعدد الجسيمات.
على سبيل المثال لـ 100 جسيم، سيكون لدينا نسيج بحجم 10x10
، ولـ 1000 جسيم، 32x32
، وهكذا.
لنقم بإنشاء نسيجنا في useMemo
:
import { DataTexture, FloatType, RGBAFormat } from "three/webgpu"; // ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... const targetPositionsTexture = useMemo(() => { const size = Math.ceil(Math.sqrt(nbParticles)); // Make a square texture const data = new Float32Array(size * size * 4); for (let i = 0; i < nbParticles; i++) { data[i * 4 + 0] = 0; // X data[i * 4 + 1] = 0; // Y data[i * 4 + 2] = 0; // Z data[i * 4 + 3] = 1; // Alpha (not needed, but required for 4-component format) } const texture = new DataTexture(data, size, size, RGBAFormat, FloatType); return texture; }, [nbParticles]); // ... }; // ...
نستخدم وظيفة Math.sqrt
لحساب حجم النسيج وننشئ Float32Array
لتخزين البيانات.
ثم نقوم بإنشاء DataTexture
مع البيانات والحجم والتنسيق والنوع. (بما أننا نستخدم نسيج مربع، يمكننا استخدام size
للعرض والارتفاع معًا.)
يُستخدم تنسيق RGBAFormat
لتخزين البيانات في 4 مكونات (R، G، B، A) وتُستخدم FloatType
لتخزين البيانات كقيم بنقط عائمة.
الآن في useEffect
، عندما تتغير geometries، سنقوم باستخراج مواقع الرؤوس وتخزينها في النسيج:
// ... import { useEffect } from "react"; import { randInt } from "three/src/math/MathUtils.js"; // ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... useEffect(() => { if (geometries.length === 0) return; for (let i = 0; i < nbParticles; i++) { const geometryIndex = randInt(0, geometries.length - 1); const randomGeometryIndex = randInt( 0, geometries[geometryIndex].attributes.position.count - 1 ); targetPositionsTexture.image.data[i * 4 + 0] = geometries[geometryIndex].attributes.position.array[ randomGeometryIndex * 3 + 0 ]; targetPositionsTexture.image.data[i * 4 + 1] = geometries[geometryIndex].attributes.position.array[ randomGeometryIndex * 3 + 1 ]; targetPositionsTexture.image.data[i * 4 + 2] = geometries[geometryIndex].attributes.position.array[ randomGeometryIndex * 3 + 2 ]; targetPositionsTexture.image.data[i * 4 + 3] = 1; } targetPositionsTexture.needsUpdate = true; }, [geometries]); // ... }; // ...
لكل جسيم، نختار بشكل عشوائي هندسة ورأس من تلك الهندسة. ثم نقوم بتخزين موقع الرأس في النسيج.
أخيرًا، نضبط needsUpdate
ليكون true
للبحث Three.js بأن النسيج بحاجة إلى تحديث.
يمكننا الآن استخدام هذا النسيج في الظلّ لدينا لعرض الجسيمات.
التحرك نحو الرؤوس
لنبدأ بقراءة قيمة النسيج لكل جسيم. في دالة useMemo
المسؤولة عن العقد:
import { useGLTF } from "@react-three/drei"; import { extend, useFrame, useThree } from "@react-three/fiber"; import { useControls } from "leva"; import { useEffect, useMemo } from "react"; import { randInt } from "three/src/math/MathUtils.js"; import { Fn } from "three/src/nodes/TSL.js"; import { ceil, sqrt, texture, vec2 } from "three/tsl"; // ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... const { nodes, uniforms, computeUpdate } = useMemo(() => { // ... // بيانات النسيج const size = ceil(sqrt(nbParticles)); const col = instanceIndex.modInt(size).toFloat(); const row = instanceIndex.div(size).toFloat(); const x = col.div(size.toFloat()); const y = row.div(size.toFloat()); const targetPos = texture(targetPositionsTexture, vec2(x, y)).xyz; // تحديث Fn // ... }, []); // ... }; extend({ SpriteNodeMaterial });
باستخدام دالتي ceil
و sqrt
، يمكننا حساب حجم النسيج وموقع كل جسيم في النسيج. نستخدم دالتي modInt
و div
لحساب العمود والصف في النسيج لكل جسيم.
ثم نستخدم دالة texture
لقراءة قيمة النسيج في الإحداثيات المحددة.
نقوم باستخراج قيمة xyz
من النسيج للحصول على موقع الهدف للجسيم.
الآن يمكننا استخدام موقع الهدف هذا لتحريك الجسيمات نحوه. لنوقف تحريك offsetPosition
(سنستخدمه لاحقًا للحركة التدفقية) ونغير موقع الإنشاء للتحرك نحو موقع الهدف:
// ... import { min } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms, computeUpdate } = useMemo(() => { // ... // تحديث Fn const computeUpdate = Fn(() => { const distanceToTarget = targetPos.sub(spawnPosition); If(distanceToTarget.length().greaterThan(0.01), () => { spawnPosition.addAssign( distanceToTarget .normalize() .mul(min(instanceSpeed, distanceToTarget.length())) ); }); // ... // offsetPosition.addAssign(vec3(instanceSpeed)); })().compute(nbParticles); // ... }, []); // ... }; // ...
نحسب المسافة إلى موقع الهدف عن طريق طرح spawnPosition
من targetPos
.
ثم نتحقق مما إذا كانت المسافة أكبر من 0.01
. إذا كانت كذلك، نحرك spawnPosition
نحو موقع الهدف عن طريق تطبيع المسافة (للحصول على الاتجاه) وضربها بالقيمة الأدنى بين instanceSpeed
والمسافة إلى موقع الهدف.
نعطل حركة offsetPosition
حاليًا.
قبل تجربتها، أوصي بتقليل عدد الجسيمات إلى 1000
لتجنب مشاكل الأداء:
// ... export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... }; // ...
جميع الجسيمات تتحرك نحو المركز (ليس في نفس الموضع بالضبط ولكن قريب بما فيه الكفاية).
هل هذا بسبب أننا ارتكبنا خطأ في قراءة الهندسة؟ كتابة النسيج؟ أم أنه خطأ في الـ shader؟
ليس تمامًا! إنه بسبب أن النموذج الذي نستخدمه يحتوي على هندسة في مساحة صغيرة جدًا.
تجهيز النموذج في Blender
لنفتح النموذج في Blender ونتفقد ما لدينا.
إذا قمنا بتوسيع RootNode
، فإنه يحتوي على mesh 01foxFinal
بداخله، والحجم عند 100
.
هذا يعني أن الهندسة أصغر بمئة مرة مما نراه. لأننا نستخدم الهندسة مباشرة، ولا نفحص تحويلات الـmesh (الموقع، الدوران، الحجم)، نحن نحرك الجسيمات نحو ثعلب صغير! 🦊
كما أن ما نراه في الـviewport رائع، يمكننا تطبيق التحويلات على الـmesh وتصديره مرة أخرى.
اضغط على A
لتحديد كل شيء، ثم Ctrl + A
لنظام Windows
أو Cmd + A
لنظام Mac
. سيفتح قائمة تحتوي على خيار لتطبيق التحويلات. اختر All Transforms
.
هذا سيطبق التحويلات على الـmesh ويعيد تعيين الحجم إلى 1
.
ثم قم بتصدير النموذج مرة أخرى كملف .glb
. لنسمه Fox-Applied.glb
.
تحقق من خيار الضغط لتقليل حجم الملف.
يمكننا الآن تحميل النموذج الجديد في مشروعنا وزيادة عدد الجسيمات.
// ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { const { scene: foxScene } = useGLTF("/models/Fox-Applied.glb"); // ... }; // ...
انخفاض FPS عند وجود عدد كبير من الجسيمات يحدث عندما تكون جميعها في نفس الموقع. يسبب ذلك مشكلة في الـoverdraw. كلما زاد عدد الجسيمات، زادت حاجة الـGPU للعمل على عرضها.
مئات الآلاف من الجسيمات تتحرك نحو الرؤوس في النموذج! 🎉
الحركة نفسها جميلة، ولكن الموقع النهائي ليس جذابًا جدًا. فقط بضع مئات من النقاط على النموذج بدلاً من انتشارها في جميع أنحاء النموذج.
هذا لأننا نحرك الجسيمات نحو الرؤوس في النموذج، ونموذجنا المنخفض البوليجون يحتوي فقط على بضعة رؤوس.
هذا ما نراه في Blender عند تفعيل وضع الـwireframe.
لحل هذه المشكلة ببساطة، يمكننا عمل remesh
للنموذج في Blender. هذا سيضيف المزيد من الرؤوس إلى النموذج باستخدام خوارزمية التقسيم.
للقيام بذلك، انقر على أيقونة Modifiers
(أيقونة المفتاح) على اليمين وأضف معدل Remesh
.
استخدم خوارزمية Voxel
وقم بضبط Voxel Size
حول 0.0250
.
هل ترى كيف يحصل نموذجنا على المزيد من الرؤوس؟
يمكننا تصدير النموذج مرة أخرى كملف .glb
. لنسمه Fox-Remesh.glb
.
عند التصدير، في قسم Data > Mesh
، تحقق من خيار Apply Modifiers
. سيطبق ذلك التعديل على النموذج قبل التصدير. من الأفضل القيام بذلك هناك حتى تتمكن من ضبطه لاحقًا إذا لزم الأمر.
لنقم بتحميل النموذج الجديد في مشروعنا:
// ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { const { scene: foxScene } = useGLTF("/models/Fox-Remesh.glb"); // ... }; // ...
أفضل بكثير، أليس كذلك؟ 🎉 سنحسنه أكثر في الأقسام القادمة.
إذا حاولنا التبديل إلى نموذج الكتاب، سنرى أننا نواجه نفس المشكلة بالضبط.
هذا وقت التدرب. قم بإعادة نفس الخطوات التي قمنا بها لنموذج الثعلب مع نموذج الكتاب.
لا تتردد في تغيير مقياس النموذج وتدويره ليكون في الوضعية المثالية لمشروعنا. لكن لا تنسى تطبيق جميع التحويلات قبل تصديره.
بمجرد أن يكون لديك نموذج الكتاب جاهزًا، قم بتحميله في المشروع:
// ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... const { scene: bookScene } = useGLTF("/models/Open Book-Remesh.glb"); // ... }; // ...
يمكننا الآن التبديل بين نماذج الثعلب 🦊 و الكتاب 📖، انتقال جميل!
لنضيف نموذجاً ثالثًا للمزيج: نص ثلاثي الأبعاد!
نص ثلاثي الأبعاد مخصص
لقد رأينا في درس النص أنه يمكننا إنشاء نص ثلاثي الأبعاد برمجيًا. ومع ذلك، سيولد هذا هندسة "فعالة" بعدد قليل من الرؤوس، مما يعطينا، مرة أخرى، نفس النتيجة غير الجيدة.
للحصول على نتيجة أفضل، سنستخدم نموذج نص ثلاثي الأبعاد تم إنشاؤه في Blender. لنقم بذلك معًا!
قم بإنشاء مشروع جديد في Blender واحذف كل شيء: A
لتحديد كل شيء، ثم X
للحذف.
ثم لإنشاء عنصر نصي، اضغط على Shift + A
لفتح قائمة Add
، واختر Text
.
سيكون النص على الأرض. لتدويره بشكل صحيح لمشروعنا، اضغط على R
للتدوير، ثم X
للتدوير على محور X
، واطبع 90
. كرر نفس الخطوات لمحور Z
.
إذا لم تقم بتحريك الكاميرا، يجب أن ترى شيئًا مشابهًا لهذا.
الآن لنقم بتخصيص النص. اضغط على Tab
للدخول في وضع تحرير النص، ثم اكتب النص الخاص بك. اضغط على Tab
مرة أخرى للخروج من وضع تحرير النص.
كن حرًا في كتابة ما تريده!
الآن، في اللوحة اليمنى، انقر على Data Object Properties
(رمز a
). من هناك يمكنك تغيير:
- الخط: في قسم
Font
، انقر على الزرOpen
واختر ملف الخط. (يجب أن يكون محملًا على جهاز الكمبيوتر الخاص بك.) - المحاذاة: في قسم
Paragraph
، قم بتغييرAlignment
إلىCenter
من أجلHorizontal
وMiddle
للـVertical
. - التباعد: يمكنك ضبط تباعد
Character
وWord
وLine
في قسمSpacing
. - الحجم: يمكنك ضبط
Size
في قسمTransform
. - البروز: في قسم
Geometry
، يمكنك إضافة قيمةExtrude
لإعطاء سمك للنص.
بمجرد أن تكون راضيًا عن النص، نحتاج إلى تحويله إلى شبكة.
في أعلى اليسار من الشاشة، انقر على Object
واختر Convert to
> Mesh
.
سيتم تحويل النص إلى شبكة. إذا كنت ترغب في تحرير النص لاحقًا، قم بتكرار النص قبل تحويله.
إذا نظرنا إلى نصنا في وضع الإطار السلكي، يمكننا رؤية أن لديه بضع رؤوس.
لنقم بإعادة تشكيله كما فعلنا مع النماذج الأخرى. حاول العثور على نقطة محورية لحجم Voxel
للحصول على عدد كافٍ من الرؤوس بدون زيادتها بناءً على الخط الذي تستخدمه.
نحن جاهزون لتصديره!
قم بتصدير النموذج كملف .glb
، ولا تنسَ تطبيق جميع التحويلات قبل تصديره. وكذلك تحقق من خيار تطبيق الموديفيرات.
نسختي موجودة في مجلد public/models
واسمها WawaSensei.glb
.
لنقم بتعديل الكود لدينا للتعامل مع هذا النموذج الثالث:
// ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { const { scene: foxScene } = useGLTF("/models/Fox-Remesh.glb"); const { scene: bookScene } = useGLTF("/models/Open Book-Remesh.glb"); const { scene: wawaScene } = useGLTF("/models/WawaSensei.glb"); const { curGeometry } = useControls({ curGeometry: { options: ["Fox", "Book", "Wawa"], value: "Fox", }, }); const geometries = useMemo(() => { const geometries = []; const sceneToTraverse = { Book: bookScene, Fox: foxScene, Wawa: wawaScene, }[curGeometry]; // ... }, [curGeometry, bookScene, foxScene, wawaScene]); // ... }; // ...
نحن جاهزون لاستخدام نموذج النص الخاص بنا في مشروعنا.
جزيئاتنا تطير إلى موضع جديد على النص! 🎉
ممتاز، لدينا الإعداد جاهز لعرض نماذج ثلاثية الأبعاد بجزيئات! لنقم بجعل العرض النهائي أكثر جاذبية بصريًا.
التفاصيل النهائية
لجعل تصور نماذجنا باستخدام الجسيمات أكثر إثارة، سنضيف بعض التفاصيل الإضافية. سيحدث ذلك فرقًا كبيرًا!
حركة التدفق
حاليًا، تتحرك الجسيمات نحو موضع ثابت وعشوائي على النموذج. يبدو رائعًا أثناء الانتقال، ولكنه بعد ذلك يبدو ثابتًا بعض الشيء.
لإنشاء حركة تدفق، سنطبق بعض الضوضاء على موضع الجسيمات:
// ... import { mx_fractal_noise_vec3 } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... const { nodes, uniforms, computeUpdate } = useMemo(() => { // ... const offsetSpeed = randValue({ min: 0.1, max: 0.5, seed: 14 }); // update Fn const computeUpdate = Fn(() => { // ... offsetPosition.addAssign( mx_fractal_noise_vec3(spawnPosition.mul(age)) .mul(offsetSpeed) .mul(deltaTime) ); // ... })().compute(nbParticles); // ... }, []); // ... }; extend({ SpriteNodeMaterial });
نستخدم دالة mx_fractal_noise_vec3
لإضافة بعض الضوضاء إلى موضع الجسيمات. نضرب spawnPosition
بـ age
لجعل الضوضاء تتطور بمرور الزمن.
ثم نضربها بسرعة offsetSpeed
عشوائية (عشوائية لكل جسيم ولكنها ثابتة بمرور الزمن.) وdeltaTime
لجعلها تتحرك بشكل مختلف بين الجسيمات المختلفة.
ارجع إلى درس WebGPU/TSL أو هذا المثال من Three.js إذا كانت لديك أسئلة حول دالة الضوضاء.
الجسيمات تتحرك الآن بحركة تدفق بينما لا تزال تمثل نماذجنا! 🤌
يبدو رائعًا، ولكن مع هذا العدد الكبير من الجسيمات، يبدو أن الكثير منها يوجه إلى نفس الموضع. لنضف بعض العشوائية إلى موضعها لإضافة بعض التنوع:
// ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... const { nodes, uniforms, computeUpdate } = useMemo(() => { // ... // Add a random offset to the particles const randOffset = vec3( range(-0.001, 0.001), range(-0.001, 0.001), range(-0.001, 0.001) ); return { uniforms, computeUpdate, nodes: { positionNode: spawnPosition.add(offsetPosition).add(randOffset), // ... }, }; }, []); // ... }; // ...
نولد إزاحة عشوائية صغيرة جدًا لكل جسيم ونضيفها إلى spawnPosition
لإضافة بعض الفروقات في موضع الجسيمات.
أفضل، أليس كذلك؟ لنضف بعض الألوان لجسيماتنا.
الألوان
هل سئمت من الجسيمات البيضاء العادية؟ لنقم بإضافة بعض الألوان لها!
أولاً، دعنا نضيف بعض الضوابط لاختيار الألوان:
// ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... const { curGeometry, startColor, endColor } = useControls({ curGeometry: { options: ["Fox", "Book", "Wawa"], value: "Fox", }, startColor: "#f55454", endColor: "white", }); // ... }; // ...
لنقم بإضافة متغير موحد ثانٍ لتخزين endColor
وتحديث كلا من المتغيرات الموحدة للألوان في useFrame
:
// ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... const { nodes, uniforms, computeUpdate } = useMemo(() => { // uniforms const uniforms = { color: uniform(color(startColor)), endColor: uniform(color(endColor)), }; // ... }, []); useFrame(() => { gl.compute(computeUpdate); uniforms.color.value.set(startColor); uniforms.endColor.value.set(endColor); }); // ... }; // ...
الآن يمكننا استخدام particleLifetimeProgress
لإجراء المعالجة بين startColor
و endColor
:
// ... import { mix } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... const { nodes, uniforms, computeUpdate } = useMemo(() => { // ... const particleLifetimeProgress = saturate(age.div(lifetime)); const colorNode = vec3( mix(uniforms.color, uniforms.endColor, particleLifetimeProgress) ); // Transform the particles to a circle const dist = length(uv().sub(0.5)); const circle = smoothstep(0.5, 0.49, dist); const finalColor = colorNode.mul(circle); // ... }, []); // ... }; // ...
نستخدم وظيفة mix
للمعالجة بين startColor
و endColor
بناءً على particleLifetimeProgress
.
الجسيمات الآن مليئة بالألوان ويمكننا ضبطها بدقة باستخدام الضوابط! 🎨
حتى مع لونين غير أبيضين، نشاهد الكثير من الأبيض. هذا لأننا نستخدم العديد من الجسيمات وفي وضع AdditiveBlending
، تضاف الألوان معاً.
يمكننا تقليل عدد الجسيمات أو عشوائية شفافيتها لإضافة المزيد من النغمات. لنقم بالخيار الثاني:
// const colorNode = vec3( // mix(uniforms.color, uniforms.endColor, particleLifetimeProgress) // ); const colorNode = vec4( mix(uniforms.color, uniforms.endColor, particleLifetimeProgress), randValue({ min: 0, max: 1, seed: 6 }) // Alpha );
ببساطة نحول colorNode
إلى vec4
ونضيف قيمة عشوائية بين 0
و 1
كقيمة ألفا.
نرى الآن لوحة ألوان أكثر تنوعاً! 🎨
لأن كل نموذج يستحق ألوانه الخاصة، دعنا نضيف كائنًا لتعريف الألوان لكل نموذج:
// ... const MODEL_COLORS = { Fox: { start: "#00ff49", end: "#0040ff", }, Book: { start: "#e8b03b", end: "white", }, Wawa: { start: "#5945ce", end: "#bbafff", }, }; // ...
سنضيف وحدة تحكم أخرى لاختيار إن كنا نستخدم الألوان المخصصة أو الألوان من وحدة التحكم لمساعدتنا في اختيار الألوان المناسبة:
// ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... const { curGeometry, startColor, endColor, debugColor } = useControls({ // ... debugColor: false, }); }; // ...
ودعنا نقوم بتحديث useFrame
لاستخدام إما أحدها أو الآخر:
// ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... useFrame(() => { gl.compute(computeUpdate); uniforms.color.value.set( debugColor ? startColor : MODEL_COLORS[curGeometry].start ); uniforms.endColor.value.set( debugColor ? endColor : MODEL_COLORS[curGeometry].end ); }); }; // ...
النماذج الآن تستخدم ألوانها الخاصة! 🎨
عندما نغير الشكل الهندسي، نرى على الفور الألوان الجديدة، مما يؤدي إلى وجود نموذج بلون النموذج القادم لوهلة قصيرة.
دعنا نقوم بانتقال ناعم بين الألوان لتكون العملية سلسة بين النماذج.
أولاً، نقوم بإنشاء لون وهمي في أعلى المكون الخاص بنا لعدم إعادة إنشاء كائن لون في كل إطار:
// ... import { Color } from "three/webgpu"; const tmpColor = new Color();
ثم نقوم بإنشاء مرجعين لتخزين القيم المتألفة ونحدث الألوان في useFrame
:
// ... import { useRef } from "react"; export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... const lerpedStartColor = useRef(new Color(MODEL_COLORS[curGeometry].start)); const lerpedEndColor = useRef(new Color(MODEL_COLORS[curGeometry].end)); useFrame((_, delta) => { gl.compute(computeUpdate); tmpColor.set(debugColor ? startColor : MODEL_COLORS[curGeometry].start); lerpedStartColor.current.lerp(tmpColor, delta); tmpColor.set(debugColor ? endColor : MODEL_COLORS[curGeometry].end); lerpedEndColor.current.lerp(tmpColor, delta); uniforms.color.value.set(lerpedStartColor.current); uniforms.endColor.value.set(lerpedEndColor.current); }); // ... }; // ...
انتقالات سلسة بين الألوان! 🌈
لنقم بإضافة لمسة نهائية لجسيماتنا: تأثير توهج!
Post processing
لجعل الجسيمات لدينا تلمع، سنستخدم تأثير post-processing وهو bloom.
دعونا نستورد ببساطة ملف PostProcessing.jsx
من WebGPU/TSL الدرس ونضيفه إلى مشروعنا.
import { useFrame, useThree } from "@react-three/fiber"; import { useEffect, useRef } from "react"; import { bloom } from "three/examples/jsm/tsl/display/BloomNode.js"; import { emissive, mrt, output, pass } from "three/tsl"; import * as THREE from "three/webgpu"; export const PostProcessing = ({ strength = 2.5, radius = 0.5, threshold = 0.25, }) => { const { gl: renderer, scene, camera } = useThree(); const postProcessingRef = useRef(null); const bloomPassRef = useRef(null); useEffect(() => { if (!renderer || !scene || !camera) { return; } const scenePass = pass(scene, camera); // Create MRT (Multiple Render Targets) scenePass.setMRT( mrt({ output, emissive, }) ); // Get texture nodes const outputPass = scenePass.getTextureNode("output"); const emissivePass = scenePass.getTextureNode("emissive"); // Create bloom pass const bloomPass = bloom(emissivePass, strength, radius, threshold); bloomPassRef.current = bloomPass; // Setup post-processing const postProcessing = new THREE.PostProcessing(renderer); const outputNode = outputPass.add(bloomPass); postProcessing.outputNode = outputNode; postProcessingRef.current = postProcessing; return () => { postProcessingRef.current = null; }; }, [renderer, scene, camera]); useFrame(() => { if (bloomPassRef.current) { bloomPassRef.current.strength.value = strength; bloomPassRef.current.radius.value = radius; bloomPassRef.current.threshold.value = threshold; } if (postProcessingRef.current) { postProcessingRef.current.render(); } }, 1); return null; };
واستوردها في مكون App
مع بعض الضوابط:
// ... import { useControls } from "leva"; import { PostProcessing } from "./components/PostProcessing"; function App() { // ... const ppSettings = useControls("Post Processing", { strength: { value: 0.8, min: 0, max: 10, step: 0.1, }, radius: { value: 0.42, min: 0, max: 10, step: 0.1, }, threshold: { value: 0.75, min: 0, max: 1, step: 0.01, }, }); return ( <> <Stats /> <Canvas // ... > {/* ... */} <PostProcessing {...ppSettings} /> </Canvas> </> ); } export default App;
لكن لرؤية تأثير التوهج، نحتاج إلى إضافة بعض القيم الانبعاثية إلى جسيماتنا:
// ... export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... const { nodes, uniforms, computeUpdate } = useMemo(() => { // ... return { uniforms, computeUpdate, nodes: { // ... colorNode: finalColor, emissiveNode: finalColor, // ... }, }; }, []); // ... }; // ...
في الوقت الحالي، نحن ببساطة نستخدم finalColor
كنقطة انبعاث للأشعة emissiveNode
.
حسنًا، إنه يتوهج، لكن لا نريد أن يحتاج المستخدمون إلى نظارات شمسية! 😎
دعونا نضيف emissiveIntensity
لكل نموذج وضبط للتحكم في اختيار القيم المناسبة:
// ... import { lerp } from "three/src/math/MathUtils.js"; const MODEL_COLORS = { Fox: { start: "#00ff49", end: "#0040ff", emissiveIntensity: 0.1, }, Book: { start: "#e8b03b", end: "white", emissiveIntensity: 0.08, }, Wawa: { start: "#5945ce", end: "#bbafff", emissiveIntensity: 0.6, }, }; const tmpColor = new Color(); export const GPGPUParticles = ({ nbParticles = 500000 }) => { // ... const { curGeometry, startColor, endColor, debugColor, emissiveIntensity } = useControls({ // ... emissiveIntensity: 0.1, debugColor: false, }); // ... const { nodes, uniforms, computeUpdate } = useMemo(() => { // uniforms const uniforms = { // ... emissiveIntensity: uniform(emissiveIntensity), }; // ... return { uniforms, computeUpdate, nodes: { // ... emissiveNode: finalColor.mul(uniforms.emissiveIntensity), // ... }, }; }, []); // ... useFrame((_, delta) => { // ... uniforms.emissiveIntensity.value = lerp( uniforms.emissiveIntensity.value, debugColor ? emissiveIntensity : MODEL_COLORS[curGeometry].emissiveIntensity, delta ); }); // ... }; // ...
نقوم بضرب finalColor
بواسطة emissiveIntensity
للتحكم في شدة انبعاث الجسيمات.
نماذجنا الآن تتوهج بالشدة الصحيحة! 🌟
بالطبع، قم بضبط الألوان وشدة الانبعاث بناءً على نماذجك وعدد الجسيمات وتفضيلاتك.
الخاتمة
لقد تعلمنا كيفية استخدام GPGPU ووظائف الحوسبة مع TSL وWebGPU. قمنا بإنشاء أداة تصوّر مخصصة لعرض النماذج ثلاثية الأبعاد باستخدام الجسيمات وأضافنا بعض التفاصيل لجعلها غنية بصريًا ومليئة بالحياة.
أنا متحمس لرؤية ما ستقومون بإنشائه بهذه المعرفة! 🚀 لا تنسوا مشاركة إبداعاتكم على X ووضع العلامة لي (@wawasensei
) وكذلك مشاركتها في مجتمع Discord.
React Three Fiber: The Ultimate Guide to 3D Web Development
✨ You have reached the end of the preview ✨
Go to the next level with Three.js and React Three Fiber!
Get full access to this lesson and the complete course when you enroll:
- 🔓 Full lesson videos with no limits
- 💻 Access to the final source code
- 🎓 Course progress tracking & completion
- 💬 Invite to our private Discord community
One-time payment. Lifetime updates included.