محرك تأثيرات بصرية

Starter pack

حتى الآن، قمنا بإنشاء مكونات مخصصة لإنشاء الجسيمات في مشاهدنا ثلاثية الأبعاد. في معظم الأحيان، نود القيام بنفس الشيء تقريباً: إنشاء الجسيمات من نقطة في الفضاء وتحريكها بمرور الوقت. (لون، حجم، موقع، إلخ.)

بدلاً من تكرار نفس الكود مراراً وتكراراً، يمكننا إنشاء محرك تأثيرات بصرية عام نسبياً يمكن استخدامه لإنشاء أنواع مختلفة من تأثيرات الجسيمات.

يأتي ذلك مع العديد من الفوائد:

  • إعادة الاستخدام: يمكنك استخدام نفس المحرك لإنشاء أنواع مختلفة من تأثيرات الجسيمات في مشاريعك.
  • الأداء: يمكن تحسين المحرك للتعامل مع عدد كبير من الجسيمات بكفاءة ولدمج أنظمة جسيمات متعددة في نظام واحد.
  • المرونة: يمكنك بسهولة تعديل سلوك الجسيمات بتغيير معلمات المحرك.
  • سهولة الاستخدام: يمكنك إنشاء تأثيرات جسيمات معقدة ببضع سطور من الكود.
  • تجنب تكرار الكود: لا تحتاج إلى كتابة نفس الكود عدة مرات.

سنستخدم هذا المحرك في الدروس القادمة لإنشاء تأثيرات متنوعة. بينما يمكنك تخطي هذا الدرس واستخدام المحرك مباشرة، فإن فهم كيفية عمله سيساعدك على فهم أعمق لكيفية التحكم في الأداء والمرونة في مشاريعك ثلاثية الأبعاد.

جاهز لبناء محرك التأثيرات البصرية الخاص بك؟ هيا لنبدأ!

جسيمات المعالج الرسومي

لقد رأينا في الدروس السابقة كيف يمكننا استخدام <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>

Random particles in the scene with an orange color

رائع! لا تزال وظائف 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

في سمة 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} />
      </>
    );
  }
);
Three.js logoReact logo

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
Unlock the Full Course – Just $85

One-time payment. Lifetime updates included.