PhotonEngine'de görüntü karıncalı kalmasın diye yaptıklarım
C++20 ile yazdığım CPU path tracer'da ışığı doğrudan örnekliyorum, iki örnekleme stratejisini güç sezgiseliyle birleştiriyorum, Rus ruletinde en büyük renk bileşenine bakıyorum ve rastgele sayıları Owen-karıştırmalı Sobol'den alıyorum. Kesişimi Embree 4'e bırakmanın ölçtüğüm etkisi ve yol boyunca düzelttiğim cam ve clearcoat hataları da yazıda var.
Bir ürün stüdyosu sahnesinde ışığın çoğu küçük bir softbox'tan ya da HDRI içindeki parlak bir pencereden gelir. Yönünü yalnızca malzemeye bakarak seçen bir path tracer bu küçük kaynaklara pek denk gelmez, görüntü de yüzlerce örnek boyunca karıncalı kalır. PhotonEngine, KeyShot tarzı bir iş akışını hedefleyerek C++20 ile yazdığım bir CPU path tracer ve render tarafındaki parçaların çoğu tam da bu gürültüyle uğraşıyor: ışığı doğrudan örneklemek, iki örnekleme stratejisini doğru ağırlıkla birleştirmek, rastgele sayıları iyi dağıtmak ve geriye kalanı OIDN'e bırakmak.
Repoyu Temmuz başında bir web sunucusu ve HTML önizlemeyle açtım; beş gün sonra GLFW ve ImGui ile masaüstü arayüzü geldi. Ekim'de motoru fazlar halinde gözden geçirdim. İlk fazda derleme ayarlarını ve dokümanı toparladım, çünkü eski README bazı özellikleri olduğundan fazla gösteriyordu. memory-bank/01-gorevler.md'deki F0.5 görevi bunları tek tek sayıyor: "unbiased", "work-stealing", Halton, ayrı DirectLighting/AO integratörleri, src/gpu ve bir "Metal" malzemesi. README'yi düzelttim, aşağıda anlattığım her şey de kodun bugünkü hali.
Tek bir integratör var
src/integrators/ altında tek bir integratör var: PathTracer. Yalnızca doğrudan ışık istersen maxBounces = 1 yetiyor. Varsayılan RenderSettings piksel başına 64 örnek ve en fazla 8 saçılma kullanıyor; arayüzdeki kaydırıcı ise 1 ile 32 arasında gidiyor. Renderer integratörü PathTracer integrator(settings.maxBounces, 3, ...) ile kurduğu için Rus ruleti her zaman depth >= 3 olduğunda, yani dördüncü yüzey etkileşiminden itibaren devreye giriyor (src/integrators/path_tracer.cpp):
float cosTheta = std::abs(wi.dot(isect.normal));
throughput *= f * (cosTheta / pdf);
if (!throughput.isFinite()) break;
// ...
if (depth >= m_russianRouletteDepth) {
float maxComponent = std::max({throughput.r, throughput.g, throughput.b});
float q = std::max(0.0f, 1.0f - maxComponent);
if (q > 0.0f) {
if (sampler.get1D() < q) {
break;
}
throughput /= (1.0f - q);
}
}Yolun hayatta kalma olasılığı throughput'un en büyük RGB bileşeni. Hayatta kalan yolu 1/(1-q) ile büyüttüğüm için beklenen değer değişmiyor. En büyük bileşen 1 ya da üstündeyse yol hiç kesilmiyor, rastgele sayı da harcanmıyor. Önceden luminansa bakıyordum; koddaki yorumun da anlattığı gibi bu, saf mavi gibi doygun renkli yolları gereksiz yere öldürüyordu.
Döngüde iki ayrıntı daha var. Emisyonu derinlik kontrolünden önce ekliyorum ki son saçılmadaki BSDF ışınının bulduğu ışık da sayılsın (PathTracer.DirectLightingAtLastVertexIsComplete testi). Bu da Ekim'deki düzeltmelerden biri: maxDepth artık PBRT'deki gibi saçılma olaylarını sayıyor ve aynı maxDepth için yollar eskisinden bir kesişim fazla izliyor. Bir de her katkı accumulate üzerinden geçiyor ve sonlu olmayanlar atılıyor, çünkü tek bir NaN ilerlemeli render'daki pikseli kalıcı olarak bozar.
Rus ruleti beklenen değeri değiştirmese de motorun ürettiği görüntüye "yansız" diyemem. Sebepleri şunlar: 8 saçılma sınırı yolu kesiyor, NaN/Inf katkılar atılıyor, isteğe bağlı adaptif örnekleme ve OIDN de son görüntüyü değiştiriyor. İlk çarpmadaki kontak AO (approxAo) fiziksel değil, bu yüzden varsayılanı aoStrength = 0. Repo'nun bulgu listesi (memory-bank/02-bulgular.md, build-2 maddesi) README'deki "unbiased" ifadesini tam da bu AO çarpanı yüzünden yanlış bulmuştu.
Işığa iki yoldan ulaşmak
Aynı ışık yolu iki stratejiyle bulunabilir. Işıktan bir nokta seçip gölge ışını atabilirsin (next event estimation, NEE) ya da BSDF'den bir yön seçip ışığa çarpmayı bekleyebilirsin. Küçük ışık kaynaklarında birincisi, pürüzsüze yakın parlak yüzeylerde ikincisi daha az gürültü verir. Multiple importance sampling (MIS) iki tahmini her birinin pdf'ine göre tartıp topluyor; ağırlıklar 1'e tamamlandığı için hiçbir katkı iki kez sayılmıyor. PhotonEngine delta olmayan her yüzey noktasında ikisini de yapıyor (yine path_tracer.cpp):
const int lightCount = explicitLights + (env ? 1 : 0);
// Düzgün ışık seçimi: her ışığın seçilme olasılığı 1/lightCount.
const float lightPmf = lightCount > 0 ? 1.0f / static_cast<float>(lightCount) : 0.0f;
// ...
float lightPdf = lightPmf * ls.pdf;
float weight = 1.0f;
bool delta = !fromEnv && lights[lightIndex]->isDelta();
if (!delta) {
weight = powerHeuristic(shadowSamples, lightPdf, 1, bsdfPdf);
}
// Tahminci: f·Li·|cos| / (n_l · pdf_l), MIS ağırlığıyla.
accumulate(throughput * f * ls.Li
* (cosTheta * weight / (lightPdf * static_cast<float>(shadowSamples))));Işığı düzgün olasılıkla seçiyorum, HDRI ortamı da ışıklardan biri sayılıyor. Ağırlık için Veach'in güç sezgiselini kullanıyorum: w = (n_f·p_f)² / ((n_f·p_f)² + (n_g·p_g)²) (src/core/math/utils.h). Işık seçim olasılığı iki tarafta da pdf'e giriyor. BSDF ışını bir yayıcıya çarptığında ya da ortama kaçtığında aynı formül bu kez powerHeuristic(1, lastBsdfPdf, shadowSamples, lightPdf) ile çağrılıyor. Nokta ve yönlü ışıklar delta olduğu için MIS'e girmiyor. Kosinüsü |cos| olarak almamın sebebi şu: buzlu cam ya da ince yaprak gibi geçirgen malzemelerde yüzeyin arkasındaki ışık da sayılmalı (TransmissionSeesLightBehindSurface testi). Ekim'deki düzeltmeden önce bu ışık hesaba girmiyordu.
MIS'in BSDF tarafının çalışması için BSDF ışınının ışığa çarpabilmesi gerekiyor. Bu yüzden Scene::addLight her alan ışığını BVH'ye, yalnızca ışık yayan siyah bir Lambert dörtgeni olarak da ekliyor. Yorumda yazdığım gibi, bunu yapmasaydım NEE'nin ağırlığı o enerjiyi atardı.
HDRI tarafında önem örneklemesini PBRT'deki 2B parça-sabit dağılımla yapıyorum (src/lights/environment_light.cpp):
for (int y = 0; y < h; ++y) {
float theta = PI * (static_cast<float>(y) + 0.5f) / static_cast<float>(h);
float sinTheta = std::sin(theta);
// ...
for (int x = 0; x < w; ++x) {
float f = m_envMap->getPixel(x, y).luminance() * sinTheta;
// ...
row[x + 1] = row[x] + f;
}
m_marginalCdf[static_cast<size_t>(y) + 1] =
m_marginalCdf[static_cast<size_t>(y)] + row[w];
}Equirect görüntüde kutba yakın satırlar küre üzerinde daha küçük alan kaplıyor, bu yüzden her pikseli luminans × sin θ ile tartıyorum. Her satırın kendi koşullu CDF'i var, satır toplamlarından da marjinal CDF kuruluyor. Örnekleme önce satırı, sonra sütunu ters CDF ve ikili aramayla seçiyor. Görüntü uzayındaki pdf, dω = 2π² sin θ du dv Jakobiyeniyle katı açı pdf'ine çevriliyor. HDRI'yi döndürmek yalnızca yön ile uv arasındaki eşlemeyi etkilediği için döndürdüğünde CDF'i yeniden kurmak gerekmiyor.
Malzemelerin çoğu Disney modelinden geçiyor
src/materials/ altında Lambertian, Mirror, Dielectric ve DisneyMaterial var. Ayrı bir metal sınıfı yok; krom gibi malzemeleri Disney'de metallic = 1 ile tanımlıyorum (assets/materials/20_chrome.json). Malzeme kütüphanesi yalnızca iki tür tanıyor: "glass" cama, diğer her şey Disney'e gidiyor.
Cam malzemesinde Ekim'de birkaç hata düzelttim. Kırılan ışın teğet düzleminde aynalanıyordu, geçirgenlikteki radyans ölçeklemesi de tersti; geçiş artık eta² ile çarpılıyor. Bu aynalanmayı yakalaması gereken ExitRefractsTowardAir testi de refractVec'i kendisiyle karşılaştırdığı için yanlış yönü doğru sanıyordu. Artık elle hesaplanmış değeri kullanıyor; test_snell.cpp de kırılmayı elle hesaplanmış Snell yönleriyle kontrol ediyor.
Disney modeli basitleştirilmiş bir Principled BRDF; içinde Burley difüz, anizotrop GGX speküler, sheen, ince yüzey difüz geçirgenliği ve clearcoat var. Dielektrikte F0 0.08·specular (Burley 2012; specular = 0.5 için 0.04). Clearcoat enerjiyi koruyor: alttaki katmanı (1 - cc·Fc(θo))·(1 - cc·Fc(θi)) ile zayıflatıyorum ve yatay açılarda enerji eklenmiyor (ClearCoatAddsHighlightWithoutEnergyGain testi). Bu testin eski adı ClearCoatAddsEnergy idi ve kaplamanın değeri her yönde artırmasını bekliyordu. Enerji koruyan bir kaplama bunu yapamaz, ben de testi buna göre değiştirdim. Disney'in tam BSDF'indeki kırılan speküler lob burada yok; cam ayrı Dielectric sınıfında duruyor.
Speküler yönleri Heitz 2018'in görünür normal (VNDF) örneklemesiyle üretiyorum (src/materials/microfacet.h):
// 1) Yarıküre uzayına ger
Vec3f vh = Vec3f(ax * wo.x, ay * wo.y, wo.z).normalized();
// ...
// 3) Görünen yarım diski örnekle: düzgün disk, sonra alt yarıyı s oranında sıkıştır
float r = std::sqrt(u1);
float phi = TWO_PI * u2;
float p1 = r * std::cos(phi);
float p2 = r * std::sin(phi);
float s = 0.5f * (1.0f + vh.z);
p2 = (1.0f - s) * std::sqrt(std::max(0.0f, 1.0f - p1 * p1)) + s * p2;
// ...
// 5) Elipsoide geri dön (normaller ters-transpoze ile dönüşür: ax, ay ile çarp)
return Vec3f(ax * nh.x, ay * nh.y, std::max(1e-6f, nh.z)).normalized();Mikro yüzey elipsoidi önce küreye geriliyor, bakış yönünden görünen yarım disk örnekleniyor ve sonuç geri ölçekleniyor. Bakış yönünden görünmeyen mikro normaller hiç üretilmediği için klasik D·cos θ örneklemesine göre daha az örnek boşa gidiyor. Pürüzlü cam da aynı fonksiyonu kullanıyor. Bu dosya cam düzeltmesiyle birlikte geldi, Disney de GGX'i artık buradan alıyor. Alfa 1e-4'ün altına inmiyor; Disney'in eski kodunda 0.016 civarının altındaki pürüzlülük inf ya da NaN üretiyordu.
Lob seçimi parametreye bağlı sabit olasılıklarla yapılıyor: clearcoat 0.25·clearCoat, speküler kalan payın dielektrikte yarısı, tam metalde tamamı; geri kalanı difüz yansıma ile difüz geçirgenlik arasında bölünüyor. Döndürülen pdf ise yalnızca seçilen lobun pdf'i değil, tüm lobların karışım pdf'i. NEE tarafında evalPdf'in verdiği pdf ile tutarlı kalması için bunun böyle olması gerekiyor; SamplePdfMatchesPdf testi de bunu sabitliyor.
Rastgele sayıları Sobol'den alıyorum
src/samplers/ altında bağımsız (PCG32), tabakalı ve Sobol örnekleyiciler var; Halton yok. Üretimde viewport, son render ve CLI yalnızca Sobol kullanıyor, tabakalı örnekleyici sadece testlerde çalışıyor. Sobol'ü ilk eklediğimde renderer'a bağlamamıştım; bağlantı birkaç commit sonra, sahne katmanıyla birlikte geldi. Uygulamada Burley 2020'yi izliyorum (src/samplers/sobol_sampler.cpp):
Vec2f SobolSampler::get2D() {
uint32_t seed = hashCombine(m_pixelHash, m_dimension++);
uint32_t index = nestedUniformScramble(m_index, seed);
uint32_t x = nestedUniformScramble(sobolDim0(index), hashCombine(seed, 0x0u));
uint32_t y = nestedUniformScramble(sobolDim1(index), hashCombine(seed, 0x1u));
return {toUnitFloat(x), toUnitFloat(y)};
}Sobol'ün yalnızca ilk iki boyutunu hesaplıyorum: boyut 0 bit ters çevirme, boyut 1 de v_k = v_{k-1} ^ (v_{k-1} >> 1) yön sayıları. Daha yüksek boyutlar "padding" ile geliyor. Her get2D çağrısı yeni bir boyut açıyor ve (piksel, boyut) karmasından bir tohum türetiyor. Sonra örnek indeksini ve iki koordinatı Laine-Karras permütasyonuyla Owen-karıştırıyor. Bunun sonucunda 1, 2, 4, 8... örnekten oluşan her ön ek iyi tabakalanmış bir ağ oluyor; PowerOfTwoPrefixesAreStratified testi de ilk 16 örneğin 4×4 ızgaranın her hücresine tam bir kez düştüğünü doğruluyor. Bu özellik ilerlemeli render'da işe yarıyor, karıştırma tohumunu pass'ler boyunca sabit tutmamın sebebi de bu. renderer.cpp'deki yorumun dediği gibi, tohum değişirse ardışık örnekler aynı dizinin devamı olmaz ve tabakalanma kaybolur.
Kesişimi Embree'ye bıraktım, kendi BVH'mi de tuttum
Üretimde ışın kesişimini Intel Embree 4 yapıyor. Motorun kendi BVH'si ise Embree bulunamazsa devreye giriyor, testlerde de doğrudan sınanıyor (src/engine/scene.cpp):
void Scene::buildAccelerator() {
m_embree.reset();
if (!gPreferOwnBvh && EmbreeAccel::available()) {
m_embree = std::make_unique<EmbreeAccel>();
m_embree->build(m_shapes);
m_bvh = BVH{};
return;
}
m_bvh.build(m_shapes);
}Bu ayrım memory-bank/00-baglam.md'deki yaklaşımdan geliyor: önemli parçaların önce yalın bir sürümünü öğrenme ve test referansı olarak kendim yazıyorum, üretimde ise en iyi kütüphaneyi kullanıyorum. Kendi BVH'm (src/geometry/bvh.cpp) 12 kovalı SAH ile kuruluyor. Centroid'ler en uzun eksende 12 kovaya dağıtılıyor ve 11 kesim deneniyor; SAH maliyeti yaprak maliyetinden düşükse düğüm bölünüyor. 255'ten fazla primitifte bölme zorunlu. Ağaç tek bir diziye düzleştiriliyor ve gezinme yakın çocuğu önce ziyaret ediyor. PHOTON_ENABLE_SIMD bayrağı yalnızca /arch:AVX2 ya da -mavx2 -mfma ekliyor; kodda elle yazılmış SIMD yok, vektörleştirilmiş gezinme Embree'nin içinde kalıyor.
Geçişin etkisini ölçüp bench/BASELINE.md'ye yazdım. Ölçümü i7-6700K'da, 960×540 çözünürlükte, 32 örnekle ve yaklaşık 13 bin üçgenlik vitrin sahnesinde yaptım. Bir örnek, 8 sekmeye kadar giden tek bir kamera yolu; tablodaki birim milyon örnek/saniye:
| Değişiklik | 8 thread | 1 thread |
|---|---|---|
| Kendi BVH, her adayda tam gölgelendirme | 2.32 | 0.46 |
| Embree 4 + köşe başına 3 gereksiz BSDF değerlendirmesinin kaldırılması | 3.64 | 0.72 |
Artışın hepsi Embree'den gelmiyor; aynı adımda köşe başına üç gereksiz BSDF değerlendirmesini de kaldırdım. Arada denediğim yalın üçgen testli gezinme 2.12 verdi; dosyada bu sonuç ölçüm gürültüsü olarak not edilmiş. Embree'ye geçmeden önceki tek thread profilinde sürenin %44'ü kesişimde, %27'si malzeme değerlendirmede geçiyordu.
İş dağıtımında work-stealing yok, src/core/threading/thread_pool.h tek bir paylaşılan kuyruk tutuyor:
std::vector<std::thread> m_threads;
std::queue<std::function<void()>> m_tasks;
std::mutex m_mutex;
std::condition_variable m_condition;parallelFor2D görüntüyü 32×32'lik karolara bölüp her birini bu kuyruğa atıyor, işi biten işçi de sıradaki karoyu alıyor. Thread başına kuyruk ya da iş çalma yok; yine de karoların maliyeti farklı olsa bile yük dengeleniyor. Havuzu hardware_concurrency() - 1 işçiyle açıyorum ki bir çekirdek arayüz thread'ine kalsın.
Kalan gürültüyü OIDN temizliyor
Bunun için Intel Open Image Denoise kullanıyorum (src/engine/denoiser.cpp):
oidn::FilterRef filter = m_impl->device.newFilter("RT");
filter.setImage("color", color.data(), oidn::Format::Float3, w, h);
// ...
if (hasAlbedo) filter.setImage("albedo", const_cast<float*>(albedo->data()), oidn::Format::Float3, w, h);
// OIDN normal kanalını yalnız albedo ile birlikte kabul eder.
if (hasNormal) filter.setImage("normal", const_cast<float*>(normal->data()), oidn::Format::Float3, w, h);
filter.set("hdr", true);Albedo ve normal AOV'lerini ayrı bir birincil ışın atmadan, path tracer'ın ilk kesişiminden topluyorum. Cihaz olarak bilerek CPU'yu seçtim, çünkü yorumda da yazdığı gibi GTX 1080 (Pascal) OIDN'in GPU modunu desteklemiyor. Viewport, sen sahneyle oynarken Fast, ardından 2, 4, 8... örnekte Balanced, hedef örneğe ulaşınca da High kalitesini kullanıyor. OIDN vcpkg'de olmadığı için scripts/fetch_deps.ps1 hazır paketi third_party/oidn'e indiriyor. OIDN yoksa arayüzdeki denoise anahtarları kapalı geliyor; son render ve CLI da o durumda 3×3 komşulukta 1/(1+lum²) ağırlıklı basit bir firefly yumuşatmasına düşüyor.
Ton eşleme kodu engine/ altında değil, src/core/image/tone_mapping.{h,cpp} içinde duruyor. Operatörler şunlar: Reinhard, Reinhard Extended, ACES (Narkowicz 2015), Filmic (Hable), AgX, Khronos PBR Neutral ve ölçüm için Linear. Varsayılan, taban renklerini koruyan PBR Neutral. Zincir de şöyle ilerliyor: pozlama (2^EV), eğri, sRGB kodlaması ve 8 bit.
Sırada bekleyenler
Motorun bilinen sınırları repo'nun kendi görev listesinde açıkça yazılı. Işık seçimi hâlâ güce göre değil, düzgün olasılıkla yapılıyor; güce orantılı alias tablosu F5.4 olarak sırasını bekliyor. GPU arka ucu henüz yok. Var olmayan src/gpu'yu gösteren PHOTON_BUILD_GPU seçeneğini kaldırdım; docs/ROADMAP.md'nin 9. fazında GTX 1080 üzerinde CUDA 12.x planlı. Kendi BVH'm Embree'nin yanında bir öğrenme ve test referansı olarak kalıyor. Elimdeki ölçülmüş performans sayıları bench/BASELINE.md'dekilerden ibaret; aynı dosyaya göre vitrin sahnesini 1080p'de, 128 örnekle ve OIDN açıkken render etmek 78 saniye sürüyor.
Projenin özetine proje sayfasından göz atabilirsin, kaynak kodu da GitHub'da.