محرك تأثيرات بصرية
حتى الآن، قمنا بإنشاء مكونات مخصصة لإنشاء الجسيمات في مشاهدنا ثلاثية الأبعاد. في معظم الأحيان، نود القيام بنفس الشيء تقريباً: إنشاء الجسيمات من نقطة في الفضاء وتحريكها بمرور الوقت. (لون، حجم، موقع، إلخ.)
بدلاً من تكرار نفس الكود مراراً وتكراراً، يمكننا إنشاء محرك تأثيرات بصرية عام نسبياً يمكن استخدامه لإنشاء أنواع مختلفة من تأثيرات الجسيمات.
يأتي ذلك مع العديد من الفوائد:
- إعادة الاستخدام: يمكنك استخدام نفس المحرك لإنشاء أنواع مختلفة من تأثيرات الجسيمات في مشاريعك.
- الأداء: يمكن تحسين المحرك للتعامل مع عدد كبير من الجسيمات بكفاءة ولدمج أنظمة جسيمات متعددة في نظام واحد.
- المرونة: يمكنك بسهولة تعديل سلوك الجسيمات بتغيير معلمات المحرك.
- سهولة الاستخدام: يمكنك إنشاء تأثيرات جسيمات معقدة ببضع سطور من الكود.
- تجنب تكرار الكود: لا تحتاج إلى كتابة نفس الكود عدة مرات.
سنستخدم هذا المحرك في الدروس القادمة لإنشاء تأثيرات متنوعة. بينما يمكنك تخطي هذا الدرس واستخدام المحرك مباشرة، فإن فهم كيفية عمله سيساعدك على فهم أعمق لكيفية التحكم في الأداء والمرونة في مشاريعك ثلاثية الأبعاد.
جاهز لبناء محرك التأثيرات البصرية الخاص بك؟ هيا لنبدأ!
جسيمات المعالج الرسومي
لقد رأينا في الدروس السابقة كيف يمكننا استخدام <Instances />
من drei لإنشاء جسيمات مراقبة في مشاهدنا ثلاثية الأبعاد.
ولكن هذا النهج يحتوي على قيد رئيسي: عدد الجسيمات التي يمكننا التعامل معها محدود بالمعالج المركزي. كلما زاد عدد الجسيمات، زادت الحاجة للمعالج المركزي للتعامل معها، مما قد يؤدي إلى مشاكل في الأداء.
يعود السبب في ذلك إلى أن <Instances />
يقوم بحساب الموقع واللون والحجم لكل <Instance />
في حلقة useFrame
الخاصة به. يمكنك مشاهدة الكود هنا.
من أجل محرك التأثيرات البصرية الخاص بنا، نريد أن نتمكن من توليد المزيد من الجسيمات أكثر مما يمكننا التعامل معه باستخدام <Instances />
. سنستخدم المعالج الرسومي للتعامل مع موقع الجسيمات ولونها وحجمها، مما يتيح لنا التعامل مع مئات الآلاف من الجسيمات (ملايين؟ 👀) دون أي مشاكل في الأداء.
الشبكة المثبتة
في حين أنه يمكننا استخدام Sprite أو Points لإنشاء الجسيمات، فإننا سنستخدم InstancedMesh.
يسمح لنا ذلك بتقديم ليس فقط الأشكال البسيطة مثل النقاط أو الصور الرمزية، بل أيضًا الأشكال ثلاثية الأبعاد مثل المكعبات والكرات والهياكل الجغرافية المخصصة.
لنقم بإنشاء مكون في مجلد جديد يسمى vfxs
يسمى VFXParticles.jsx
:
import { useMemo, useRef } from "react"; import { PlaneGeometry } from "three"; export const VFXParticles = ({ settings = {} }) => { const { nbParticles = 1000 } = settings; const mesh = useRef(); const defaultGeometry = useMemo(() => new PlaneGeometry(0.5, 0.5), []); return ( <> <instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}> <meshBasicMaterial color="orange" /> </instancedMesh> </> ); };
نقوم بإنشاء geometry الذي سيستخدم لكل جسيم. في هذه الحالة، نستخدم شكل مستوٍ بسيط بحجم 0.5
على كلا المحورين. لاحقًا سنضيف دعامة لتمرير أي شكل نريده.
يأخذ مكون instancedMesh
ثلاث حجج:
- geometry للجسيمات.
- material للجسيمات. قمنا بتمرير
null
لتعريفها بشكل تصريحي داخل المكون. - عدد instances التي سيكون المكون قادرًا على التعامل معها. بالنسبة لنا، يمثل ذلك الحد الأقصى لعدد الجسيمات التي يمكن عرضها في نفس الوقت.
لنقم باستبدال المكعب البرتقالي بمكون VFXParticles
في ملف Experience.jsx
:
// ... import { VFXParticles } from "./vfxs/VFXParticles"; export const Experience = () => { return ( <> {/* ... */} <VFXParticles /> </> ); };
يمكنك رؤية جسيم برتقالي واحد في منتصف المشهد. هذا هو مكون VFXParticles
لدينا.
عدد الجسيمات لدينا مضبوط على 1000
لكن يمكننا رؤية واحد فقط. هذا لأن جميعها يتم تقديمها في نفس الموضع (0, 0, 0)
. دعونا نغيّر ذلك.
مصفوفة المثيلات
يستخدم الـ instanced mesh مصفوفة لتعريف موضع، ودوران، ومقياس كل مثيل. من خلال تحديث خاصية instanceMatrix للشبكة الخاصة بنا، يمكننا نقل، وتدوير، وتكبير كل جسيم بشكل فردي.
بالنسبة لكل مثيل، تكون المصفوفة عبارة عن مصفوفة 4x4 تمثل تحول الجسيم. تتيح لنا فئة Matrix4 من Three.js تكوين
و تحليل
المصفوفة لضبط/الحصول على الموضع، والدوران، والمقياس للجسيم بطريقة أكثر قابلية للقراءة البشرية.
في الجزء العلوي من إعلان VFXParticles
، دعونا نعلن بعض المتغيرات الوهمية للتلاعب بالجسيمات دون إعادة إنشاء Vectors وMatrices في كثير من الأحيان:
// ... import { Euler, Matrix4, PlaneGeometry, Quaternion, Vector3 } from "three"; const tmpPosition = new Vector3(); const tmpRotationEuler = new Euler(); const tmpRotation = new Quaternion(); const tmpScale = new Vector3(1, 1, 1); const tmpMatrix = new Matrix4();
الآن دعونا ننشئ دالة emit
لإعداد الجسيمات لدينا:
// ... import { useEffect } from "react"; // ... export const VFXParticles = ({ settings = {} }) => { // ... const emit = (count) => { for (let i = 0; i < count; i++) { const position = [ randFloatSpread(5), randFloatSpread(5), randFloatSpread(5), ]; const scale = [ randFloatSpread(1), randFloatSpread(1), randFloatSpread(1), ]; const rotation = [ randFloatSpread(Math.PI), randFloatSpread(Math.PI), randFloatSpread(Math.PI), ]; tmpPosition.set(...position); tmpRotationEuler.set(...rotation); tmpRotation.setFromEuler(tmpRotationEuler); tmpScale.set(...scale); tmpMatrix.compose(tmpPosition, tmpRotation, tmpScale); mesh.current.setMatrixAt(i, tmpMatrix); } }; useEffect(() => { emit(nbParticles); }, []); // ... };
تقوم دالة emit
بالتكرار عبر عدد الجسيمات التي نريد إصدارها وتعيين موضع ودوران ومقياس عشوائي لكل جسيم. ثم نقوم بتكوين المصفوفة بهذه القيم ونضبطها على المثيل في الفهرس الحالي.
يمكنك رؤية جسيمات عشوائية في المشهد. لكل جسيم موضع، ودوران، ومقياس عشوائي.
لتحريك الجسيمات الخاصة بنا، سنقوم بتعريف خصائص مثل العمر الافتراضي، السرعة، الاتجاه حتى يمكن إجراء الحساب على الـ GPU.
قبل القيام بذلك، نحتاج إلى التبديل إلى material shader مخصص للتعامل مع هذه الخصائص حيث أنه لا يتاح لنا الوصول والسيطرة على خصائص meshBasicMaterial
.
المادة الخاصة بالجزيئات
هدفنا الأول هو عدم مشاهدة أي تغيير بين meshBasicMaterial
و shaderMaterial
الجديد. سنقوم بإنشاء مادة shader بسيطة تقوم بعرض الجزيئات بالطريقة نفسها التي يقوم بها meshBasicMaterial
حاليًا.
في مكون VFXParticles
، لنقم بإنشاء مادة shader جديدة:
// ... import { shaderMaterial } from "@react-three/drei"; import { extend } from "@react-three/fiber"; import { Color } from "three"; const ParticlesMaterial = shaderMaterial( { color: new Color("white"), }, /* glsl */ ` varying vec2 vUv; void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(instanceMatrix * vec4(position, 1.0)); vUv = uv; } `, /* glsl */ ` uniform vec3 color; varying vec2 vUv; void main() { gl_FragColor = vec4(color, 1.0); }` ); extend({ ParticlesMaterial });
هذه مادة shader بسيطة جدًا تأخذ color
uniform وتعرض الجزيئات بهذا اللون. الشيء الجديد هنا هو instanceMatrix
الذي نستخدمه للحصول على position
و rotation
و scale
لكل جزيء.
لاحظ أننا لم نحتاج إلى إعلان
instanceMatrix
كخاصية حيث أن هذه واحدة من الخصائص المدمجة فيWebGLProgram
عند استخدام instancing. يمكن العثور على المزيد من المعلومات هنا.
لنقم باستبدال meshBasicMaterial
بـ ParticlesMaterial
الجديد:
<instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}> <particlesMaterial color="orange" /> </instancedMesh>
رائع! لا تزال وظائف position
و rotation
و scale
تعمل كما هو متوقع. يتم عرض الجزيئات بلون برتقالي مختلف قليلاً. هذا لأننا لا نأخذ البيئة في الاعتبار في مادة shader الخاصة بنا. للحفاظ على الأمور بسيطة، سنبقيها هكذا.
نحن الآن جاهزون لإضافة خصائص مخصصة لجزيئاتنا لتحريكها.
السمات المثبتة لـ Buffer
حتى الآن، استخدمنا فقط خاصية instanceMatrix
، وسنقوم الآن بإضافة سمات مخصصة للحصول على تحكم أكبر في كل جزيء.
من أجل ذلك، سنستخدم InstancedBufferAttribute من Three.js.
سنضيف السمات التالية لجزيئاتنا:
instanceColor
: vector3 يمثل لون الجزيء.instanceColorEnd
: vector3 يمثل اللون الذي سيتحول إليه الجزيء مع مرور الوقت.instanceDirection
: vector3 يمثل الاتجاه الذي سيتحرك فيه الجزيء.instanceSpeed
: float لتحديد سرعة تحرك الجزيء في اتجاهه.instanceRotationSpeed
: vector3 لتحديد سرعة دوران الجزيء لكل محور.instanceLifetime
: vector2 لتحديد العمر الافتراضي للجزيء. القيمة الأولى (x
) هي وقت البدء، والقيمة الثانية (y
) هي العمر الافتراضي/المدة. مع المزيج مع الـ time uniform، يمكننا حساب العمر و التقدم وإذا كان الجزيء حيًا أو ميتًا.
لنقم بإنشاء المدخلات المختلفة لسماتنا:
// ... import { useState } from "react"; // ... export const VFXParticles = ({ settings = {} }) => { // ... const [attributeArrays] = useState({ instanceColor: new Float32Array(nbParticles * 3), instanceColorEnd: new Float32Array(nbParticles * 3), instanceDirection: new Float32Array(nbParticles * 3), instanceLifetime: new Float32Array(nbParticles * 2), instanceSpeed: new Float32Array(nbParticles * 1), instanceRotationSpeed: new Float32Array(nbParticles * 3), }); // ... }; // ...
أنا أستخدم useState
لإنشاء المدخلات المختلفة لسماتنا لتجنب إعادة إنشائها في كل رندر. اخترت عدم استخدام الخطاف useMemo
لأن تغيير الحد الأقصى لعدد الجزيئات خلال دورة حياة المكون ليس شيئًا نريد التعامل معه.
يُستخدم Float32Array
لتخزين قيم السمات. نضرب عدد الجزيئات في عدد مكونات السمة للحصول على العدد الإجمالي للقيم في المصفوفة.
في سمة instanceColor
، ستُمثل القيم الثلاثة الأولى لون الجزيء الأول، والقيم الثلاثة التالية ستُمثل لون الجزيء الثاني، وهكذا.
لنبدأ بالتعرف على InstancedBufferAttribute
وكيفية استخدامه. لتنفيذ ذلك، سنقوم بتطبيق سمة instanceColor
:
// ... import { DynamicDrawUsage } from "three"; export const VFXParticles = ({ settings = {} }) => { // ... return ( <> <instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}> <particlesMaterial color="orange" /> <instancedBufferAttribute attach={"geometry-attributes-instanceColor"} args={[attributeArrays.instanceColor]} itemSize={3} count={nbParticles} usage={DynamicDrawUsage} /> </instancedMesh> </> ); };
في <instancedMesh />
نضيف <instancedBufferAttribute />
لتعريف السمة instanceColor
. نقوم بربطها بخاصية geometry-attributes-instanceColor
للشبكية. نمرر المصفوفة attributeArrays.instanceColor
كمصدر للبيانات، ونحدد itemSize
بقيمة 3
حيث أن لدينا vector3، و count
بقيمة nbParticles
.
خاصية usage
يتم ضبطها على DynamicDrawUsage
لإخبار الريندر أن البيانات سيتم تحديثها بشكل متكرر. القيم الأخرى الممكنة والتفاصيل الأكثر يمكن الاطلاع عليها هنا.
لن نقوم بتحديثها في كل إطار، ولكن في كل مرة نقوم بإصدار جزيئات جديدة، سيتم تحديث البيانات. يكفي لاعتبارها كـ DynamicDrawUsage
.
ممتاز، لنقم بإنشاء متغير tmpColor
وهمي أعلى ملفنا للتعامل مع ألوان الجزيئات:
// ... const tmpColor = new Color();
الآن دعونا نقوم بتحديث دالة emit
لضبط سمة instanceColor
:
const emit = (count) => { const instanceColor = mesh.current.geometry.getAttribute("instanceColor"); for (let i = 0; i < count; i++) { // ... tmpColor.setRGB(Math.random(), Math.random(), Math.random()); instanceColor.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3); } };
نبدأ بالحصول على سمة instanceColor
من هندسة الشبكة. بعد ذلك نقوم بالتكرار على عدد الجزيئات التي نريد إصدارها ونضبط لونًا عشوائيًا لكل جزيء.
لنقم بتحديث particlesMaterial
لاستخدام سمة instanceColor
بدلاً من الـ color uniform:
const ParticlesMaterial = shaderMaterial( { // color: new Color("white"), }, /* glsl */ ` varying vec2 vUv; varying vec3 vColor; attribute vec3 instanceColor; void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(instanceMatrix * vec4(position, 1.0)); vUv = uv; vColor = instanceColor; } `, /* glsl */ ` varying vec3 vColor; varying vec2 vUv; void main() { gl_FragColor = vec4(vColor, 1.0); }` ); // ...
لقد أضفنا attribute vec3 instanceColor;
إلى شادر القمة وقمنا بتحديد الـ vColor
كمتغير لنقل اللون إلى شادر الفراجمنت. ثم نقوم بتحديد لون الفراجمنت بـ vColor
لعرض الجزيئات بلونها.
لقد قمنا بتعيين لون عشوائي بنجاح لكل جزيء. يتم عرض الجزيئات بلونها.
ممتاز، لنقم بإضافة السمات الأخرى لجزيئاتنا. أولاً، دعونا نقوم بتحديث دالة emit
لضبط سمات instanceColorEnd
وinstanceDirection
وinstanceLifetime
وinstanceSpeed
وinstanceRotationSpeed
بقيم عشوائية:
const emit = (count) => { const instanceColor = mesh.current.geometry.getAttribute("instanceColor"); const instanceColorEnd = mesh.current.geometry.getAttribute("instanceColorEnd"); const instanceDirection = mesh.current.geometry.getAttribute("instanceDirection"); const instanceLifetime = mesh.current.geometry.getAttribute("instanceLifetime"); const instanceSpeed = mesh.current.geometry.getAttribute("instanceSpeed"); const instanceRotationSpeed = mesh.current.geometry.getAttribute( "instanceRotationSpeed" ); for (let i = 0; i < count; i++) { // ... tmpColor.setRGB(Math.random(), Math.random(), Math.random()); instanceColor.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3); tmpColor.setRGB(Math.random(), Math.random(), Math.random()); instanceColorEnd.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3); const direction = [ randFloatSpread(1), randFloatSpread(1), randFloatSpread(1), ]; instanceDirection.set(direction, i * 3); const lifetime = [randFloat(0, 5), randFloat(0.1, 5)]; instanceLifetime.set(lifetime, i * 2); const speed = randFloat(5, 20); instanceSpeed.set([speed], i); const rotationSpeed = [ randFloatSpread(1), randFloatSpread(1), randFloatSpread(1), ]; instanceRotationSpeed.set(rotationSpeed, i * 3); } };
وقم بإنشاء مكونات instancedBufferAttribute
لكل سمة:
<instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}> <particlesMaterial color="orange" /> <instancedBufferAttribute attach={"geometry-attributes-instanceColor"} args={[attributeArrays.instanceColor]} itemSize={3} count={nbParticles} usage={DynamicDrawUsage} /> <instancedBufferAttribute attach={"geometry-attributes-instanceColorEnd"} args={[attributeArrays.instanceColorEnd]} itemSize={3} count={nbParticles} usage={DynamicDrawUsage} /> <instancedBufferAttribute attach={"geometry-attributes-instanceDirection"} args={[attributeArrays.instanceDirection]} itemSize={3} count={nbParticles} usage={DynamicDrawUsage} /> <instancedBufferAttribute attach={"geometry-attributes-instanceLifetime"} args={[attributeArrays.instanceLifetime]} itemSize={2} count={nbParticles} usage={DynamicDrawUsage} /> <instancedBufferAttribute attach={"geometry-attributes-instanceSpeed"} args={[attributeArrays.instanceSpeed]} itemSize={1} count={nbParticles} usage={DynamicDrawUsage} /> <instancedBufferAttribute attach={"geometry-attributes-instanceRotationSpeed"} args={[attributeArrays.instanceRotationSpeed]} itemSize={3} count={nbParticles} usage={DynamicDrawUsage} /> </instancedMesh>
الآن حان الوقت لإعطاء الحياة لجزيئاتنا عن طريق تنفيذ حركة الجزيئات ومنطق اللون والعمر.
العمر الزمني للجسيمات
لحساب سلوك الجسيمات الخاصة بنا، نحتاج إلى تمرير الوقت المنقضي إلى الـ shader الخاص بنا. سنستخدم الخاصية uniforms
للـ shaderMaterial
لتمرير الزمن إليها.
لنقم بتحديث ParticlesMaterial
لإضافة الـ uniform uTime
:
const ParticlesMaterial = shaderMaterial( { uTime: 0, }, /* glsl */ ` uniform float uTime; // ... `, /* glsl */ ` // ... ` );
وفي حلقة useFrame
، سنقوم بتحديث الـ uniform uTime
:
// ... import { useFrame } from "@react-three/fiber"; // ... export const VFXParticles = ({ settings = {} }) => { // ... useFrame(({ clock }) => { if (!mesh.current) { return; } mesh.current.material.uniforms.uTime.value = clock.elapsedTime; }); // ... };
في الـ vertex shader، سنحسب عمر و تقدم كل جسيم بناءً على الـ uniform uTime
و الخاصية instanceLifetime
. سنقوم بتمرير الـ progress إلى الـ fragment shader لتحريك الجسيمات عبر varying باسم vProgress
.
uniform float uTime; varying vec2 vUv; varying vec3 vColor; varying vec3 vColorEnd; varying float vProgress; attribute float instanceSpeed; attribute vec3 instanceRotationSpeed; attribute vec3 instanceDirection; attribute vec3 instanceColor; attribute vec3 instanceColorEnd; attribute vec2 instanceLifetime; // x: startTime, y: duration void main() { float startTime = instanceLifetime.x; float duration = instanceLifetime.y; float age = uTime - startTime; vProgress = age / duration; gl_Position = projectionMatrix * modelViewMatrix * vec4(instanceMatrix * vec4(position, 1.0)); vUv = uv; vColor = instanceColor; vColorEnd = instanceColorEnd; }
يتم حساب الـ عمر بطرح startTime
من uTime
. ثم يتم حساب الـ progress بقسمة الـ عمر على duration
.
الآن في الـ fragment shader، سنقوم بإجراء تداخل للون الجسيمات بين instanceColor
و instanceColorEnd
بناءً على الـ progress:
varying vec3 vColor; varying vec3 vColorEnd; varying float vProgress; varying vec2 vUv; void main() { vec3 finalColor = mix(vColor, vColorEnd, vProgress); gl_FragColor = vec4(finalColor, 1.0); }
يمكننا رؤية تغير لون الجسيمات بمرور الوقت لكن نواجه مشكلة. جميع الجسيمات مرئية في البداية بينما وقت بدءها عشوائي. نحتاج إلى إخفاء الجسيمات التي لم تولد بعد.
لمنع عرض الجسيمات التي لم تولد أو ماتت، سنستخدم الكلمة المفتاحية discard
في الـ fragment shader:
// ... void main() { if (vProgress < 0.0 || vProgress > 1.0) { discard; } // ... }
تشير الكلمة المفتاحية discard
إلى المحرك أن يتجاهل الجزء الحالي وعدم عرضه.
رائع، الجسيمات الآن تولد، تعيش، وتموت بمرور الوقت. يمكننا الآن إضافة منطق الحركة والتدوير.
حركة الجسيمات
باستخدام الاتجاه والسرعة والعمر للجسيمات، يمكننا حساب الموضع بمرور الوقت.
في الـ vertex shader، دعونا نضبط gl_Position
لأخذ الاتجاه والسرعة للجسيمات بعين الاعتبار.
أولاً، نقوم بتطبيع الاتجاه لتجنب حركة الجسيمات بسرعة أكبر عندما لا يكون الاتجاه متجه وحدة:
vec3 normalizedDirection = length(instanceDirection) > 0.0 ? normalize(instanceDirection) : vec3(0.0);
ثم نقوم بحساب الإزاحة للجسيمة بناءً على السرعة والعمر:
vec3 offset = normalizedDirection * age * instanceSpeed;
دعونا نحصل على موضع الكائن:
vec4 startPosition = modelMatrix * instanceMatrix * vec4(position, 1.0); vec3 instancePosition = startPosition.xyz;
ونطبق الإزاحة عليه:
vec3 finalPosition = instancePosition + offset;
أخيرًا، نحصل على موضع الموديل "mvPosition" بتطبيق modelViewMatrix على finalPosition لتحويل الموضع إلى فضاء العالم:
vec4 mvPosition = modelViewMatrix * vec4(finalPosition, 1.0);
ونطبق projectionMatrix لتحويل الموضع العالمي إلى فضاء الكاميرا:
gl_Position = projectionMatrix * mvPosition;
ها هو الشيدر الخاص بنا حتى الآن:
uniform float uTime; varying vec2 vUv; varying vec3 vColor; varying vec3 vColorEnd; varying float vProgress; attribute float instanceSpeed; attribute vec3 instanceRotationSpeed; attribute vec3 instanceDirection; attribute vec3 instanceColor; attribute vec3 instanceColorEnd; attribute vec2 instanceLifetime; // x: startTime, y: duration void main() { float startTime = instanceLifetime.x; float duration = instanceLifetime.y; float age = uTime - startTime; vProgress = age / duration; vec3 normalizedDirection = length(instanceDirection) > 0.0 ? normalize(instanceDirection) : vec3(0.0); vec3 offset = normalizedDirection * age * instanceSpeed; vec4 startPosition = modelMatrix * instanceMatrix * vec4(position, 1.0); vec3 instancePosition = startPosition.xyz; vec3 finalPosition = instancePosition + offset; vec4 mvPosition = modelViewMatrix * vec4(finalPosition, 1.0); gl_Position = projectionMatrix * mvPosition; vUv = uv; vColor = instanceColor; vColorEnd = instanceColorEnd; }
الجسيمات تتحرك الآن في اتجاهات مختلفة بسرعات متفاوتة. هذا فوضوي ولكنه بسبب القيم العشوائية لدينا.
دعونا نعالج ذلك من خلال تعديل القيم العشوائية في دالة emit
لرؤية أوضح لحركة الجسيمات:
for (let i = 0; i < count; i++) { const position = [ randFloatSpread(0.1), randFloatSpread(0.1), randFloatSpread(0.1), ]; const scale = [randFloatSpread(1), randFloatSpread(1), randFloatSpread(1)]; const rotation = [ randFloatSpread(Math.PI), randFloatSpread(Math.PI), randFloatSpread(Math.PI), ]; tmpPosition.set(...position); tmpRotationEuler.set(...rotation); tmpRotation.setFromEuler(tmpRotationEuler); tmpScale.set(...scale); tmpMatrix.compose(tmpPosition, tmpRotation, tmpScale); mesh.current.setMatrixAt(i, tmpMatrix); tmpColor.setRGB(1, 1, 1); instanceColor.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3); tmpColor.setRGB(0, 0, 0); instanceColorEnd.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3); const direction = [randFloatSpread(0.5), 1, randFloatSpread(0.5)]; instanceDirection.set(direction, i * 3); const lifetime = [randFloat(0, 5), randFloat(0.1, 5)]; instanceLifetime.set(lifetime, i * 2); const speed = randFloat(1, 5); instanceSpeed.set([speed], i); const rotationSpeed = [ randFloatSpread(1), randFloatSpread(1), randFloatSpread(1), ]; instanceRotationSpeed.set(rotationSpeed, i * 3); }
البداية تأخذ شكلًا!
سنضيف ضوابط واجهة مستخدم بسيطة لتعديل المتغيرات لاحقًا. الآن دعونا نكمل الجسيمات بإضافة منطق الدوران.
بينما قمنا بفصل الاتجاه والسرعة للحركة، للدوران سنستخدم خاصية واحدة instanceRotationSpeed
لتعريف سرعة الدوران لكل محور.
في الـ vertex shader، يمكننا حساب دوران الجسيم بناءً على سرعة الدوران والعمر:
vec3 rotationSpeed = instanceRotationSpeed * age;
ثم، لكي نتمكن من تطبيق هذا "إزاحة الدوران" على الجسيم، نحتاج إلى تحويله إلى مصفوفة دوران:
mat4 rotX = rotationX(rotationSpeed.x); mat4 rotY = rotationY(rotationSpeed.y); mat4 rotZ = rotationZ(rotationSpeed.z); mat4 rotationMatrix = rotZ * rotY * rotX;
rotationX
، rotationY
، وrotationZ
هي دوال تُرجع مصفوفة دوران حول محاور X، Y، وZ على التوالي. سنقوم بتعريفها حول الدالة main
في الـ vertex shader:
mat4 rotationX(float angle) { float s = sin(angle); float c = cos(angle); return mat4( 1, 0, 0, 0, 0, c, -s, 0, 0, s, c, 0, 0, 0, 0, 1 ); } mat4 rotationY(float angle) { float s = sin(angle); float c = cos(angle); return mat4( c, 0, s, 0, 0, 1, 0, 0, -s, 0, c, 0, 0, 0, 0, 1 ); } mat4 rotationZ(float angle) { float s = sin(angle); float c = cos(angle); return mat4( c, -s, 0, 0, s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ); }
لمعرفة المزيد حول مصفوفات الدوران، يمكنك مراجعة هذه المقالة في ويكيبيديا، الدورات المذهلة Game Math Explained Simply لـ Simon Dev، أو قسم المصفوفة من كتاب الشيدر.
أخيرًا، يمكننا تطبيق مصفوفة الدوران على الموضع البدائي للجسيم:
vec4 startPosition = modelMatrix * instanceMatrix * rotationMatrix * vec4(position, 1.0);
لنجرّب ذلك:
الجسيمات تتحرك الآن، وتغير لونها، وتدور! ✨
رائع، لدينا الآن قاعدة صلبة لـ محرك التأثيرات البصرية الخاص بنا. قبل إضافة ميزات وضوابط أكثر، دعونا نحضر الجزء المهم الثاني من المحرك: المصدر.
الانبعاثات
غالبًا ما يتم التغاضي عن جزء انبعاث الجسيمات في الأنظمة التعليمية والأمثلة. لكن هذا جزء حيوي لدمج الجسيمات في مشاريعك بسهولة وكفاءة:
- بسهولة لأن مكون
<VFXParticles />
سيكون في قمة هرم التطبيق الخاص بك ويمكن لـ المبعث أن يصدرها من أي مكون فرعي في مشهدك. مما يجعل من السهل إصدارها من نقطة محددة، إرفاقها بـ كائن متحرك، أو عظمة متحركة. - بكفاءة لأنك بدلاً من إعادة إنشاء instanced meshes، وتجميع shader materials، وتعيين attributes في كل مرة تريد فيها إصدار الجسيمات، يمكنك إعادة استعمال نفس مكون VFXParticles واستدعاء وظيفة لإصدار الجسيمات بالتهيئة المطلوبة.
useVFX
نريد أن نكون قادرين على استدعاء دالة emit
من مكون VFXParticles
من أي مكان في مشروعنا. للقيام بذلك، سنقوم بإنشاء hook مخصصة تسمى useVFX
ستكون مسؤولة عن تسجيل و إلغاء تسجيل المبعثات من مكون VFXParticles.
سنستخدم Zustand لأنه وسيلة بسيطة وفعالة لإدارة الحالة العامة في React مع أداء رائع.
لنضفها إلى مشروعنا:
yarn add zustand
في مجلد vfxs
، لنقم بإنشاء ملف VFXStore.js
:
import { create } from "zustand"; export const useVFX = create((set, get) => ({ emitters: {}, registerEmitter: (name, emitter) => { if (get().emitters[name]) { console.warn(`Emitter ${name} already exists`); return; } set((state) => { state.emitters[name] = emitter; return state; }); }, unregisterEmitter: (name) => { set((state) => { delete state.emitters[name]; return state; }); }, emit: (name, ...params) => { const emitter = get().emitters[name]; if (!emitter) { console.warn(`Emitter ${name} not found`); return; } emitter(...params); }, }));
ما يحتويه:
- emitters: كائن يقوم بتخزين جميع المبعثات من مكونات
VFXParticles
. - registerEmitter: دالة لتسجيل مبعث باسم معطى.
- unregisterEmitter: دالة لإلغاء تسجيل مبعث باسم معطى.
- emit: دالة لاستدعاء المبعث باسم معطى ومعلمات من أي مكان في مشروعنا.
لنقم بتوصيله بمكون VFXParticles
:
// ... import { useVFX } from "./VFXStore"; // ... export const VFXParticles = ({ name, settings = {} }) => { // ... const registerEmitter = useVFX((state) => state.registerEmitter); const unregisterEmitter = useVFX((state) => state.unregisterEmitter); useEffect(() => { // emit(nbParticles); registerEmitter(name, emit); return () => { unregisterEmitter(name); }; }, []); // ... }; // ...
أضفنا خاصية name
لمكون VFXParticles
لتحديد المبعث. ثم استخدمنا useVFX
للحصول على دوال registerEmitter
وunregisterEmitter
.
نقوم باستدعاء registerEmitter
مع name
ودالة emit
داخل hook useEffect
لتسجيل المبعث عند تحميل المكون وإلغاء تسجيله عند إزالته.
في مكون Experience
، دعنا نضيف خاصية name
لمكون VFXParticles
:
// ... import { VFXParticles } from "./vfxs/VFXParticles"; export const Experience = () => { return ( <> {/* ... */} <VFXParticles name="sparks" /> </> ); };
VFXEmitter
الآن بعد أن أصبح لدينا ملحق useVFX
يمكننا إنشاء مكون VFXEmitter
الذي سيكون مسؤولًا عن إنتاج الجزيئات من مكون VFXParticles
الخاص بنا.
في مجلد vfxs
، دعونا نقم بإنشاء ملف VFXEmitter.jsx
:
import { forwardRef, useImperativeHandle, useRef } from "react"; import { useVFX } from "./VFXStore"; export const VFXEmitter = forwardRef( ({ emitter, settings = {}, ...props }, forwardedRef) => { const { duration = 1, nbParticles = 1000, spawnMode = "time", // time, burst loop = false, delay = 0, } = settings; const emit = useVFX((state) => state.emit); const ref = useRef(); useImperativeHandle(forwardedRef, () => ref.current); return ( <> <object3D {...props} ref={ref} /> </> ); } );
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.