خوارزمية عرض حجم سريعة وسهلة


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

كان لدي ShaderToy ثلاث مهام رئيسية:

  1. التنفيذ في الوقت الحقيقي
  2. بساطة
  3. صحة بدنية (... أو شيء من هذا القبيل)

سأبدأ بمشهد الرمز الفارغ هذا. لن أخوض في تفاصيل التنفيذ ، لأنها ليست مثيرة للاهتمام للغاية ، ولكن سأخبرك بإيجاز من أين نبدأ:

  1. تتبع شعاع الأجسام المعتمة. جميع الأشياء بدائية ذات تقاطعات بسيطة بالأشعة (مستوى واحد و 3 مجالات)
  2. لحساب الإضاءة ، يتم استخدام تظليل Phong ، وفي ثلاثة مصادر ضوئية كروية ، يتم استخدام معامل توهين مخصص للضوء. أشعة الظلال ليست مطلوبة ، لأننا نضيء الطائرة فقط.

إليك ما يبدو عليه:

لقطة شاشة ShaderToy

سنقدم الحجم كممر منفصل يختلط بمشهد غير شفاف ؛ هذا مشابه للطريقة التي تعالج بها جميع محركات العرض في الوقت الفعلي الأسطح غير الشفافة والشفافة بشكل فردي.

الجزء 1: محاكاة الحجم


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

لنبدأ بواحد بسيط وإضافة المجال هنا:

لقطة شاشة ShaderToy

الآن سنضيف مجالًا آخر ونستخدم اقترانًا سلسًا لدمج وظائف المسافة للكرات. أخذت هذا الرمز مباشرة من صفحة Inigo ، ولكن للتوضيح ، سأقوم بإدراجه هنا:

// Taken from https://iquilezles.org/www/articles/distfunctions/distfunctions.htm
float sdSmoothUnion( float d1, float d2, float k ) 
{
    float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 );
    return mix( d2, d1, h ) - k*h*(1.0-h); 
}

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

لقطة شاشة ShaderToy

لذا ، لدينا شيء على شكل دمعة ، لكننا بحاجة إلى شيء أشبه بالسحابة أكثر من قطرة. ميزة رائعة لـ SDF هي مدى سهولة تشويه السطح عن طريق إضافة القليل من الضوضاء إلى SDF. لذا دعنا نضيف بعض الحركة البراونية الكسرية (fBM) فوق الضوضاء ، باستخدام الموضع لفهرسة وظيفة الضوضاء. قام Inigo Kiles أيضًا بتغطية هذا الموضوع في مقالة رائعة عن ضوضاء fBM. إليك ما ستبدو عليه الصورة مع ضوضاء fBM:

لقطة شاشة ShaderToy

غرامة! بفضل ضجيج fBM ، بدأ الكائن فجأة يبدو أكثر إثارة للاهتمام!

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

لقطة شاشة ShaderToy

ستكون اللمسة الأخيرة هي التغيير في مؤشر xz لضجيج fBM بمرور الوقت ، بحيث يبدو الحجم مثل الضباب الدائر. أثناء التنقل ، تبدو جيدة جدًا!

لقطة شاشة ShaderToy

رائع ، لدينا شيء مثل السحابة! رمز حساب SDF مضغوط أيضًا:

float QueryVolumetricDistanceField( in vec3 pos)
{    
    vec3 fbmCoord = (pos + 2.0 * vec3(iTime, 0.0, iTime)) / 1.5f;
    float sdfValue = sdSphere(pos, vec3(-8.0, 2.0 + 20.0 * sin(iTime), -1), 5.6);
    sdfValue = sdSmoothUnion(sdfValue,sdSphere(pos, vec3(8.0, 8.0 + 12.0 * cos(iTime), 3), 5.6), 3.0f);
    sdfValue = sdSmoothUnion(sdfValue, sdSphere(pos, vec3(5.0 * sin(iTime), 3.0, 0), 8.0), 3.0) + 7.0 * fbm_4(fbmCoord / 3.2);
    sdfValue = sdSmoothUnion(sdfValue, sdPlane(pos + vec3(0, 0.4, 0)), 22.0);
    return sdfValue;
}

هذا هو مجرد جعل كائن معتم. نحن بحاجة إلى ضباب رائع جميل!

كيف نجعلها في شكل حجم وليس جسم غير شفاف؟ دعونا نتحدث أولاً عن الفيزياء التي نحاكيها. الحجم هو عدد ضخم من الجسيمات في مساحة معينة من الفضاء. وعندما أقول "ضخم" ، أعني "ضخم". لدرجة أن نمذجة كل من هذه الجسيمات اليوم مهمة مستحيلة ، حتى للعرض في وضع عدم الاتصال. من الأمثلة الجيدة على ذلك النار والضباب والغيوم. بالمعنى الدقيق للكلمة ، كل شيء هو حجم ، ولكن من أجل سرعة الحسابات ، من الأسهل إغلاق أعيننا على هذا والتظاهر بأنه ليس كذلك. نحن نمثل تراكم هذه الجسيمات كقيم كثافة يتم تخزينها عادة في نوع من الشبكة ثلاثية الأبعاد (أو شيء أكثر تعقيدًا ، على سبيل المثال ، في OpenVDB).

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

لقطة شاشة ShaderToy

يبدو الرمز الزائف لأنه يشبه:

for n steps along the camera ray:
   Calculate what % of your ray hit particles (i.e. were absorbed) and needs lighting
   for m lights:
      for k steps towards the light:
         Calculate % of light that were absorbe in this step
      Calculate lighting based on how much light is visible
Blend results on top of opaque objects pass based on % of your ray that made it through the volume

أي أننا نتعامل مع حسابات معقدة O (n * m * k). لذا سيتعين على GPU العمل بجد.

نحسب الامتصاص


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

  1. قم بإجراء أشعة داخل الحجم
  2. احسب الامتصاص / الإضاءة في كل خطوة

لحساب مقدار الضوء الذي يتم امتصاصه في كل نقطة ، نستخدم قانون Bouguer - Lambert - Beer ، الذي يصف توهين الضوء عند المرور عبر مادة. الحسابات بسيطة بشكل مدهش:

float BeerLambert(float absorptionCoefficient, float distanceTraveled)
{
    return exp(-absorptionCoefficient * distanceTraveled);
}

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

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

float opaqueVisiblity = 1.0f;
const float marchSize = 0.6f;
for(int i = 0; i < MAX_VOLUME_MARCH_STEPS; i++) {
	volumeDepth += marchSize;
	if(volumeDepth > opaqueDepth) break;
	
	vec3 position = rayOrigin + volumeDepth*rayDirection;
	bool isInVolume = QueryVolumetricDistanceField(position) < 0.0f;
	if(isInVolume) 	{
		float previousOpaqueVisiblity = opaqueVisiblity;
		opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
		float absorptionFromMarch = previousOpaqueVisiblity - opaqueVisiblity;
		for(int lightIndex = 0; lightIndex < NUM_LIGHTS; lightIndex++) {
			float lightDistance = length((GetLight(lightIndex).Position - position));
			vec3 lightColor = GetLight(lightIndex).LightColor * GetLightAttenuation(lightDistance);  
			volumetricColor += absorptionFromMarch * volumeAlbedo * lightColor;
		}
		volumetricColor += absorptionFromMarch * volumeAlbedo * GetAmbientLight();
	}
}

وهذا ما نحصل عليه مع هذا:

لقطة شاشة ShaderToy

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

التظليل الذاتي


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

float GetLightVisiblity(in vec3 rayOrigin, in vec3 rayDirection, in float maxT, in int maxSteps, in float marchSize) {
    float t = 0.0f;
    float lightVisiblity = 1.0f;
    for(int i = 0; i < maxSteps; i++) {                       
        t += marchSize;
        if(t > maxT) break;

        vec3 position = rayOrigin + t*rayDirection;
        if(QueryVolumetricDistanceField(position) < 0.0) {
            lightVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
        }
    }
    return lightVisiblity;
}

تعطينا إضافة التظليل الذاتي ما يلي:

لقطة شاشة ShaderToy

تنعيم الحواف


في الوقت الحالي ، أحب بالفعل حجمنا. عرضته على القائد الموهوب بقسم VFX التابع للتحالف ، جيمس شارب. لاحظ على الفور أن حواف المجلد بدت حادة للغاية. وهذا صحيح تمامًا - فالكائنات مثل الغيوم متناثرة باستمرار في المساحة المحيطة بها ، لذلك تختلط حوافها مع المساحة الفارغة حول الحجم ، مما يجب أن يؤدي إلى إنشاء حواف ناعمة جدًا. قدم لي جيمس فكرة رائعة - لتقليل الكثافة اعتمادًا على مدى قربنا من الحافة. ونظرًا لأننا نعمل مع وظائف المسافة بعلامة ، فمن السهل جدًا تنفيذها! لذا دعنا نضيف دالة يمكن استخدامها لطلب الكثافة في أي نقطة في المجلد:

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
    return sdfMultiplier;
}

ثم نقوم ببساطة بتقليصها إلى قيمة الامتصاص:

opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT * GetFogDensity(position), marchSize);

وإليك ما يبدو عليه:

لقطة شاشة ShaderToy

دالة الكثافة


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

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
   return sdfMultiplier * abs(fbm_4(position / 6.0) + 0.5);
}

وهكذا حصلنا على ما يلي:

لقطة شاشة ShaderToy

التعتيم الذاتي غير الشفاف


يبدو الحجم بالفعل جميلًا جدًا! لكن القليل من الضوء لا يزال يتسرب من خلالها. هنا نرى كيف يتسرب اللون الأخضر حيث يجب أن يمتصه الحجم بالتأكيد:

لقطة شاشة ShaderToy

يحدث هذا لأنه يتم تقديم كائنات معتمة قبل تقديم وحدة التخزين ، لذلك لا تأخذ في الاعتبار التظليل الناتج عن وحدة التخزين. من السهل جدًا إصلاح ذلك - لدينا وظيفة GetLightVisiblity التي يمكن استخدامها لحساب التظليل ، لذلك نحتاج فقط إلى تسميتها لإضاءة كائن معتم. نحصل على ما يلي:

لقطة شاشة ShaderToy

بالإضافة إلى إنشاء ظلال جميلة متعددة الألوان ، فهذا يساعد على تحسين الظلال وزيادة حجم المشهد. بالإضافة إلى ذلك ، بفضل الحواف الناعمة للحجم ، نحصل على ظلال ناعمة ، على الرغم من حقيقة أننا ، بصرامة ، نعمل مع مصادر الإضاءة الثابتة. هذا كل شئ! يمكن فعل المزيد هنا ، ولكن يبدو لي أنني حققت الجودة البصرية التي أحتاجها ، مع الحفاظ على البساطة النسبية للمثال.

التحسينات


في النهاية ، سأذكر باختصار بعض التحسينات الممكنة:

  1. قبل إجراء أشعة في اتجاه مصدر الضوء ، من الضروري التحقق من قيمة انقراض الضوء ما إذا كانت كمية كبيرة من هذا الضوء تصل بالفعل إلى النقطة المعنية. في تنفيذي ، ألقي نظرة على سطوع الضوء مضروبًا في بياض المادة ، وأتأكد من أن القيمة كبيرة بما يكفي للقيام بمسح الشعاع.
  2. , , raymarching
  3. raymarching . , . , raymarching , .


هذا كل شئ! أنا شخصياً فوجئت أنه يمكنك إنشاء شيء صحيح جسديًا تمامًا بمثل هذه الكمية الصغيرة من التعليمات البرمجية (حوالي 500 سطر). شكرا للقراءة ، آمل أن تكون مثيرة للاهتمام.

وملاحظة أخرى: إليك تغيير ممتع - أضفت انبعاث الضوء استنادًا إلى مسافة SDF لإنشاء تأثير انفجار. بعد كل شيء ، الانفجارات ليست كثيرة أبدا.

لقطة شاشة ShaderToy

All Articles