جسيمات GPGPU باستخدام TSL وWebGPU

Starter pack

في هذا الدرس، سنقوم بإنشاء مئات الآلاف من الجسيمات العائمة لإنشاء نماذج ثلاثية الأبعاد ونصوص ثلاثية الأبعاد باستخدام 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.

حزمة بداية جسيمات GPGPU

لنقم باستبدال المجسم الوردي بمكوّن جديد يسمى 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.

هل يعمل؟ دعونا نتحقق من ذلك!

Particles with random positions full white

يوب! كل شيء أبيض! ⬜️

تكبير قليلاً؟

Particles with random positions zoomed out

حسناً، يمكننا رؤية الجسيمات، إنها فقط كبيرة جدًا وقامت بتلوين الشاشة بأكملها! 😮‍💨

دعونا نصلح ذلك بتعيين 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.

Fox model

وأيضاً نموذج الكتاب المفتوح بواسطة Quaternius CC-BY.

Open Book model

كلاهما في مجلد 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 ونتفقد ما لدينا.

Fox model in Blender

إذا قمنا بتوسيع RootNode، فإنه يحتوي على mesh 01foxFinal بداخله، والحجم عند 100.

هذا يعني أن الهندسة أصغر بمئة مرة مما نراه. لأننا نستخدم الهندسة مباشرة، ولا نفحص تحويلات الـmesh (الموقع، الدوران، الحجم)، نحن نحرك الجسيمات نحو ثعلب صغير! 🦊

كما أن ما نراه في الـviewport رائع، يمكننا تطبيق التحويلات على الـmesh وتصديره مرة أخرى.

اضغط على A لتحديد كل شيء، ثم Ctrl + A لنظام Windows أو Cmd + A لنظام Mac. سيفتح قائمة تحتوي على خيار لتطبيق التحويلات. اختر All Transforms.

Apply all transforms in Blender

هذا سيطبق التحويلات على الـmesh ويعيد تعيين الحجم إلى 1.

ثم قم بتصدير النموذج مرة أخرى كملف .glb. لنسمه Fox-Applied.glb.

Fox model export GLTF

تحقق من خيار الضغط لتقليل حجم الملف.

يمكننا الآن تحميل النموذج الجديد في مشروعنا وزيادة عدد الجسيمات.

// ...

export const GPGPUParticles = ({ nbParticles = 500000 }) => {
  const { scene: foxScene } = useGLTF("/models/Fox-Applied.glb");
  // ...
};

// ...

انخفاض FPS عند وجود عدد كبير من الجسيمات يحدث عندما تكون جميعها في نفس الموقع. يسبب ذلك مشكلة في الـoverdraw. كلما زاد عدد الجسيمات، زادت حاجة الـGPU للعمل على عرضها.

مئات الآلاف من الجسيمات تتحرك نحو الرؤوس في النموذج! 🎉

الحركة نفسها جميلة، ولكن الموقع النهائي ليس جذابًا جدًا. فقط بضع مئات من النقاط على النموذج بدلاً من انتشارها في جميع أنحاء النموذج.

هذا لأننا نحرك الجسيمات نحو الرؤوس في النموذج، ونموذجنا المنخفض البوليجون يحتوي فقط على بضعة رؤوس.

Wireframe representation of the model in Blender

هذا ما نراه في Blender عند تفعيل وضع الـwireframe.

لحل هذه المشكلة ببساطة، يمكننا عمل remesh للنموذج في Blender. هذا سيضيف المزيد من الرؤوس إلى النموذج باستخدام خوارزمية التقسيم.

للقيام بذلك، انقر على أيقونة Modifiers (أيقونة المفتاح) على اليمين وأضف معدل Remesh.

استخدم خوارزمية Voxel وقم بضبط Voxel Size حول 0.0250.

هل ترى كيف يحصل نموذجنا على المزيد من الرؤوس؟

يمكننا تصدير النموذج مرة أخرى كملف .glb. لنسمه Fox-Remesh.glb.

Fox model export GLTF Apply modifiers

عند التصدير، في قسم 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 للحذف.

Blender delete everything

ثم لإنشاء عنصر نصي، اضغط على Shift + A لفتح قائمة Add، واختر Text.

سيكون النص على الأرض. لتدويره بشكل صحيح لمشروعنا، اضغط على R للتدوير، ثم X للتدوير على محور X، واطبع 90. كرر نفس الخطوات لمحور Z.

Blender rotated text

إذا لم تقم بتحريك الكاميرا، يجب أن ترى شيئًا مشابهًا لهذا.

الآن لنقم بتخصيص النص. اضغط على Tab للدخول في وضع تحرير النص، ثم اكتب النص الخاص بك. اضغط على Tab مرة أخرى للخروج من وضع تحرير النص.

Blender text edit mode

كن حرًا في كتابة ما تريده!

الآن، في اللوحة اليمنى، انقر على Data Object Properties (رمز a). من هناك يمكنك تغيير:

  • الخط: في قسم Font، انقر على الزر Open واختر ملف الخط. (يجب أن يكون محملًا على جهاز الكمبيوتر الخاص بك.)
  • المحاذاة: في قسم Paragraph، قم بتغيير Alignment إلى Center من أجل Horizontal وMiddle للـVertical.
  • التباعد: يمكنك ضبط تباعد Character و Word و Line في قسم Spacing.
  • الحجم: يمكنك ضبط Size في قسم Transform.
  • البروز: في قسم Geometry، يمكنك إضافة قيمة Extrude لإعطاء سمك للنص.

Blender text properties

بمجرد أن تكون راضيًا عن النص، نحتاج إلى تحويله إلى شبكة.

في أعلى اليسار من الشاشة، انقر على Object واختر Convert to > Mesh.

Blender convert text to mesh

سيتم تحويل النص إلى شبكة. إذا كنت ترغب في تحرير النص لاحقًا، قم بتكرار النص قبل تحويله.

إذا نظرنا إلى نصنا في وضع الإطار السلكي، يمكننا رؤية أن لديه بضع رؤوس.

Blender text wireframe

لنقم بإعادة تشكيله كما فعلنا مع النماذج الأخرى. حاول العثور على نقطة محورية لحجم Voxel للحصول على عدد كافٍ من الرؤوس بدون زيادتها بناءً على الخط الذي تستخدمه.

Blender text remesh

نحن جاهزون لتصديره!

قم بتصدير النموذج كملف .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.

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.