İstanbul Park'ı bir SVG çiziminden tarayıcıda sürülebilir bir piste çevirmek
Sitenin ana sayfasındaki 3D pist, Wikimedia'daki Istanbul_park.svg'den çıkarılmış 329 noktalık bir çizgiden üretiliyor. Bu çizginin sürülebilir bir yola nasıl dönüştüğünü, yolun ilk başta neden görünmediğini, ağaçların karedeki üçgenlerin neredeyse tamamını neden yediğini ve farın bütün ekranı neden kararttığını anlatıyorum.
Sitenin ana sayfasında tıklayıp sürebileceğin küçük bir 3D sahne var. Pist, Tuzla'daki İstanbul Park: ana düzlük, T1-T2, dört tepe noktalı T8, arka düzlük ve T12-T14 bölümü gerçeğindeki gibi saat yönünün tersine dönüyor. WASD ile sürüyorsun; Shift nitro, Space el freni, L farlar. Düzlüklerin kenarındaki panolarda projelerim duruyor, panonun yanındaki oka girip E'ye basarsan o projeye uğruyorsun. Araçlar ve dekor iki hazır setten geliyor: Kenney Starter Kit Racing (MIT) ve KayKit City Builder Bits (CC0).
Bu hale gelmesi üç adım sürdü. circuit.ts'in git geçmişindeki ilk hali (23 Eylül) Starter Kit Racing'in kendi 16 kutucukluk varsayılan turunu kullanıyordu. 30 Eylül'deki commit'te İstanbul Park'ı aynı setin kutucuklarıyla 46 karelik bir ızgaraya oturttum. Virajlar 90 derece, düzlükler kutucuk boyundaydı. Pistin adı doğruydu ama şekli tutmuyordu. Şimdiki sürümde yol için kutucukları bıraktım: pist gerçek çizgiden üretiliyor, yol, bordürler ve bariyerler de bu çizgiden hesaplanan şeritler.
Bir SVG'den 329 nokta
Çizgi Wikimedia Commons'taki Istanbul_park.svg'den geliyor (Will Pittenger, CC BY-SA 3.0). istanbulPark.json içinde kaynağın notu, yaklaşık 2 birim aralıklı 329 noktalık merkez çizgisi, 14 virajın bu dizideki indeksleri ve sektör bitişleri var. Lisans atıf istediği için ana sayfanın altbilgisinde SVG'nin bağlantısı ve yazarı yazıyor.
src/components/graphics/circuit.ts dosyası birkaç sabitle başlıyor ve pistle ilgili her şey bunlara bağlı:
import trace from "./istanbulPark.json";
/** Kit models (cars, decor tiles, trucks) are imported at this scale. */
export const TRACK_SCALE = 0.75;
// ...
/** Real lap → play world. */
const CIRCUIT_SCALE = 0.9;
/** Tightest centreline radius the road can take without its inner edge folding over. */
const MIN_RADIUS = 8;
/** Centreline sample spacing. */
const SPACING = 1.25;
/** Drivable half-width (the kit lane). */
export const ROAD_HALF = 4.75 * TRACK_SCALE - 0.2;
// ...
const RAW: Point[] = trace.centerline.map(([x, z]) => [x * CIRCUIT_SCALE, z * CIRCUIT_SCALE]);Bunların içinde en önemlisi MIN_RADIUS. Yolun yarı genişliği kitin şeridi kadar, yaklaşık 3,4 birim. Merkez çizgisinin yarıçapı çok küçülürse virajın iç kenarı kendi üstüne katlanıyor. Gerçek pistin en dar virajları bu ölçekte o sınırın altında kalıyordu.
Yalnızca dar virajları gevşetmek
Ham izi olduğu gibi kullanmıyorum. Önce 1,25 birim aralıkla yeniden örnekliyorum, sonra iki geçiş hafif yumuşatma yapıyorum: her nokta iki komşusuyla 1-2-1 ağırlıkla ortalanıyor. Asıl iş bundan sonra başlıyor: her noktada, üç örnek önceki ve üç örnek sonraki noktadan geçen çemberin eğriliğini hesaplıyorum. Yarıçapı 8 birimden küçük olan noktaları ve iki yanlarındaki üçer komşuyu işaretleyip ortalamayı yalnızca onlara uyguluyorum. Düzlüklere ve geniş virajlara hiç dokunmuyorum. circuit.ts içindeki buildLine():
function buildLine(): Point[] {
let line = resample(RAW, SPACING);
for (let pass = 0; pass < 2; pass += 1) line = line.map((_, i) => average(line, i));
// Relax only the stretches tighter than MIN_RADIUS (plus a little run-in either side);
// straights and the big sweepers are never touched.
for (let iteration = 0; iteration < 4000; iteration += 1) {
const tight = line.map((_, i) => Math.abs(bend(line, i, 3)) * MIN_RADIUS > 1);
if (!tight.includes(true)) break;
const near = tight.map((_, i) => [-3, -2, -1, 0, 1, 2, 3].some((d) => loopAt(tight, i + d)));
line = line.map((p, i) => (near[i] ? average(line, i) : p));
if (iteration % 20 === 19) line = resample(line, SPACING);
}
line = resample(line, SPACING);
// Index 0 sits on the real start/finish line, where the trace begins.
// ...
return [...line.slice(start), ...line.slice(0, start)];
}Ortalama alınan bölgelerde noktaların aralığı bozuluyor, o yüzden her 20 turda bir yeniden örnekliyorum. Bugünkü izle döngü 212 turda bitiyor. Ortaya 429 örnekli, yaklaşık 536 birimlik kapalı bir tur çıkıyor ve en dar yarıçap 8,1 oluyor. Son satırda diziyi döndürüp 0. indeksi izin ilk noktasına, yani gerçek başlangıç-bitiş çizgisine en yakın örneğe getiriyorum. Tur zamanlayıcısı bitişi 0 sayıyor.
Her şey bu çizgiye asılı
Her örnekte konum, teğet, normal, işaretli eğrilik ve yay uzunluğu var. Geri kalan her şey bunlardan türüyor. Viraj numaraları, izdeki indekslerine en yakın örneğe bağlanıyor. Virajın hangi yanının iç taraf olduğunu dokuz örneğin eğrilik toplamının işareti söylüyor. Bordürler yarıçapı 45 birimin altındaki her virajın iç tarafına, 18'in altındakilerde dış tarafına da konuyor. Çakıl havuzları T1, T8, T9 ve T12'nin dışında. Bariyer merkezden sabit uzaklıkta duruyor. Yalnız dar bir virajın iç tarafında, duvarın kendi yarıçapı 1,5 birimin altına inmesin diye içeri çekiliyor.
Proje panolarının yerini seçerken kamerayı da hesaba katmam gerekti. Kovalama kamerası sahneye güneydoğudan bakıyor, yani yolun güneyine ya da doğusuna konan bir pano kamerayla arabanın arasına girer. Bu yüzden circuit.ts içindeki getShowcaseSpots() panoları yalnızca yolun kuzeyine ya da batısına koyuyor:
/**
* Boards on open ground beside a straight, in lap order. The chase camera looks from the
* south-east, so a board only stands north or west of its road: never between lens and car.
*/
export function getShowcaseSpots(count = MAX_SHOWCASES): ShowcaseSpot[] {
// ...
for (let index = 0; index < COUNT; index += 1) {
if (!calm(index) || pits.has(index) || circularGap(index, 0) < 24) continue;
if (turns.some((m) => circularGap(index, m.index) < 6)) continue;
if (taken.some((spot) => circularGap(spot.index, index) < SHOWCASE_GAP)) continue;
const p = TRACK[index];
for (const side of [1, -1] as const) {
if ((p.nx + p.nz) * side >= 0) continue;
// ...
// Nothing of another stretch of road behind the board either.
const behind = offsetAt(index, side * (SHOWCASE_LATERAL + 5));
if (distanceToTrack(behind.x, behind.z) < BARRIER + 2) continue;
// ...calm() panonun önündeki yolun en az 40 birim yarıçaplı olmasını istiyor. Kod 12 pano için yer arıyor, bu izde 8 tanesi sığıyor.
İlk yol görünmedi
Yolun kendisi CircuitRoad.tsx içinde. Asfalt, kenar çizgileri, bordürler ve çakıl aynı ribbon() fonksiyonundan çıkıyor. İki yanal ofset arasında her örnek adımı için bir dörtgen kuruluyor ve bütün tur birkaç birleştirilmiş şeride, yani birkaç çizim çağrısına iniyor.
İlk denemede şeritler görünmedi. Dörtgenlerin yarısı yukarıdan bakınca saat yönünde sarılmıştı ve GPU onları arka yüz sayıp atıyordu (backface culling). İç ve dış kenar işaretli ofset olarak geliyor. Kenar çizgisi ya da bordür yolun öbür yanına geçince, yani side -1 olunca, köşelerin sırası da tersine dönüyor. Şimdi her dörtgenin sarım yönünü 2D bir çapraz çarpımla seçiyorum. src/components/graphics/CircuitRoad.tsx içinde:
function ribbon(
indices: number[],
inner: (i: number, k: number) => number,
outer: (i: number, k: number) => number,
// ...
) {
// ...
for (let k = 0; k < indices.length - 1; k += 1) {
// ...
const a0 = offsetAt(a, inner(a, k));
const a1 = offsetAt(a, outer(a, k));
const b0 = offsetAt(b, inner(b, k + 1));
const b1 = offsetAt(b, outer(b, k + 1));
// ...
// Wind every quad counter-clockwise seen from above, or the GPU culls it as a back face.
const up = (b0.z - a0.z) * (b1.x - a0.x) - (b0.x - a0.x) * (b1.z - a0.z) > 0;
const quad = up ? [v0, v1, v2, v0, v2, v3] : [v0, v2, v1, v0, v3, v2];
// ...
}
}Geometri oluşturulduktan sonra toGeometry() aşağı bakan normalleri de ters çeviriyor, hepsi yukarı baksın diye.
1,85 milyon üçgenin 1,69 milyonu ağaç
Kare başına kaç üçgen çizildiğine WebGL çizim çağrılarını sayan bir sayaçla baktım ve tablo netti: karedeki yaklaşık 1,85 milyon üçgenin 1,69 milyonu tek bir instanced mesh'ten geliyordu. Bu, kitin decoration-forest modeliydi. 2006 üçgenlik model pistin çevresine 843 kez yerleştirilmişti ve gölge geçişinde bir kez daha çiziliyordu.
Instanced mesh'in tek bir sınırlayıcı küresi var ve bu küre pistin etrafındaki bütün ormanı sarıyor. Kamera nereye bakarsa baksın küre görüş alanında kalıyor, frustum culling hiç devreye girmiyor. Bu yüzden forestChunks() ormanı pistin merkezine göre 8 dilime bölüyor. Her dilimi de piste 60 birimden yakın ve uzak diye ikiye ayırıyor. Gölgeyi yalnızca yakındakiler düşürüyor. Fonksiyon src/components/graphics/StreetDecor.tsx içinde:
// The kit forest is ~90% of the scene's triangles (843 patches x 2k tris on desktop). One
// instanced mesh is never frustum-culled, so it is cut into sectors around the circuit and the
// ones behind the camera drop out; only patches near the road cast shadows anyone can see.
const FOREST_SECTORS = 8;
const FOREST_SHADOW_REACH = 60;
export function forestChunks(cells: Place[]) {
const { minX, maxX, minZ, maxZ } = getTrackBounds();
const cx = (minX + maxX) / 2;
const cz = (minZ + maxZ) / 2;
const chunks = new Map<string, { shadows: boolean; places: Place[] }>();
for (const cell of cells) {
const turn = Math.atan2(cell.z - cz, cell.x - cx) / (Math.PI * 2) + 0.5;
const sector = Math.floor(turn * FOREST_SECTORS) % FOREST_SECTORS;
const shadows = distanceToTrack(cell.x, cell.z) < FOREST_SHADOW_REACH;
const key = `${sector}:${shadows ? "near" : "far"}`;
// ...
}
return [...chunks.entries()];
}Masaüstünde orman 16 parçaya ayrılıyor. Her parça kendi sınırlayıcı küresini örneklerin konumundan hesaplıyor (computeBoundingSphere()) ve kameranın arkasında kalan parçalar çizilmiyor. Gölge haritasını da 4096'dan 2048'e indirdim. Gölge kutusu yaklaşık 32×36 birim olduğu için 2048 ile de birim başına hâlâ 60 civarı texel düşüyor. Gölge geçişini de sayınca, kare başına üçgen sayısı kovalama görünümünde 3,71 milyondan 1,40 milyona, kuşbakışı görünümde 3,75 milyondan 1,99 milyona indi.
Yerleşim kendini denetliyor
Pist bir çizgiden üretildiği için tek bir sabiti değiştirmek her şeyi kaydırabiliyor: bir pano yola girer, bir bariyer virajı keser. Bunu yakalamak için assertStreetLayout() fonksiyonunu yazdım. npm run test:selfcheck içindeki streetDecor.check.ts onu Node'da çalıştırıyor. Fonksiyon ardışık örnekler arasında 1,6 birimden büyük boşluk olmadığını, merkez çizgisinin her yerde asfaltta kaldığını ve yol kenarına yapışmış bir arabanın bile duvara değmediğini denetliyor. Ayrıca çıkış noktasının önünde 14 birim düz yol, yana sürünce tutan bir bariyer, en az 8 pano, birbirine binmeyen tetikleme alanları ve tam 14 viraj olması gerekiyor. Orman bölünmesi de sonradan listeye girdi. StreetDecor.tsx içinden:
export function assertStreetLayout() {
// The centreline is drivable end to end and never runs into a barrier.
const samples = getTrackSamples();
for (let i = 0; i < samples.length; i += 1) {
const a = samples[i];
const b = trackSample(i + 1);
if (Math.hypot(a.x - b.x, a.z - b.z) > 1.6) throw new Error(`lap breaks after sample ${i}`);
if (!onTrack(a.x, a.z)) throw new Error(`centreline off the asphalt at ${i}`);
// A car hugging either road edge doesn't touch the wall either.
for (const side of [1, -1]) {
const edge = offsetAt(i, side * (ROAD_HALF - 0.5));
const nudged = pushOutOfWalls(edge.x, edge.z, 0.5);
if (Math.hypot(nudged.x - edge.x, nudged.z - edge.z) > 1e-6) throw new Error(`wall on the road at ${i}`);
}
}
// ...
if (getTurnMarkers().length !== 14) throw new Error("Istanbul Park has 14 turns");
// ...
if (chunks.length < FOREST_SECTORS) throw new Error("forest is not split for culling");
}Bu kontroller WebGL'e hiç dokunmuyor, yalnızca geometriye bakıyor. Birazdan anlatacağım hatayı yakalayamamalarının sebebi de bu.
Yağmur bakılan yere yağıyor
Sahnede gece, yağmur, kar ve şimşek var. Damlalar eskiden kutuydu. Perspektif kutuları büyüttüğü için kameraya yakın damlalar kalın beyaz çubuklara dönüşüyordu. Şimdi her damla 1 piksellik bir çizgi: iki köşe, parlak bir baş, saydam bir kuyruk. Hepsi her karede yeniden yazılıyor. Masaüstünde alan 1248 damlaya göre kurulu (780 çarpı en yüksek yoğunluk 1,6), yoğunluk ayarına göre de bunların bir kısmı çiziliyor.
Damlaların nerede yağacağı ayrı bir soruydu. Kamera arabanın yaklaşık 15 birim arkasında durduğu için, kameranın çevresine kurulan bir yağmur kutusu bütün damlaları ekranın dışında bırakırdı. Ben de hacmi bakış ışınının yere değdiği noktaya taşıyorum. src/components/graphics/VoxelWorldStage.tsx içinde:
// Rain falls where the camera is looking (the car in the chase view), not around the lens:
// the kit camera sits ~15 units back, so a box around it would put every drop off-screen.
camera.getWorldDirection(lookDir);
const reach = lookDir.y < -0.05 ? Math.min(60, -camera.position.y / lookDir.y) : 20;
focus.copy(camera.position).addScaledVector(lookDir, reach);
// Nudge the volume past the car so few drops hang right in front of the lens.
focus.x += lookDir.x * 5;
focus.z += lookDir.z * 5;
// ...
for (let i = 0; i < drops; i += 1) {
const seed = rainSeeds[i];
const falling = seed.y - t * seed.speed * 1.6 - seed.phase * verticalSpan;
const y = (((falling % verticalSpan) + verticalSpan) % verticalSpan) - 0.1;
const x = camX + ((((seed.x * 1.7 + y * wind) % span) + span) % span) - span * 0.5;
const z = camZ + ((((seed.z * 1.7) % span) + span) % span) - span * 0.5;
// ...
}Aynı döngüde farın yere yatırılmış konisi de hesaplanıyor. Koninin içine düşen damlalar sıcak bir renge kayıyor ve parlıyor.
Far bütün kareyi kararttı
Farın görünür hüzmesi, açık uçlu bir koniyle eklemeli (additive) karışan küçük bir shader'dan oluşuyor. Lambaya yakın ve kameraya dönük yüzlerde parlak, kenarda ve uçta sıfıra iniyor. Eklemeli olduğu için yalnızca ışık ekleyebilir, ekranı karartmasının yolu yok. Ama gerçek bir ekran kartında, Windows'ta Chrome'da (ANGLE üzerinden D3D11), far yanınca bütün kare siyaha döndü. Yazılımla çizen SwiftShader'da aynı sahne düzgün görünüyordu.
Suçlu NaN'dı: pow(0.0, k) ve sıfır uzunluklu bir vektörü normalize etmek orada NaN üretiyor. Tek bir NaN piksel yetiyor, çünkü bloom geçişinin mip zinciri o pikseli bütün kareye yayıyor. İlk denediğim if (!(a > 0.0005)) discard; işe yaramadı, çünkü derleyici NaN olmayacağını varsayıyor. İşe yarayan, her girdiyi clamp ile sıkıştırmak oldu, çünkü HLSL'de min ve max NaN'ı düşürüyor. Bölmeden önce de uzunluğu kontrol ediyorum ve pow yerine facing * sqrt(facing) yazıyorum. VoxelWorldStage.tsx içindeki createBeamMaterial() shader'ları:
// vertex
vec3 n = mat3(modelMatrix) * normal;
vec3 v = cameraPosition - world.xyz;
// normalize(0) is NaN on D3D (ANGLE): guard the lengths instead of trusting them.
float len = length(n) * length(v);
vFacing = len > 1e-6 ? abs(dot(n, v)) / len : 0.0;
// ...
// fragment
// Bloom smears a single NaN pixel over the whole frame, so every input is clamped
// (HLSL max/min drop NaN) and pow() is avoided: pow(0.0, k) is NaN there too.
float along = clamp(vAlong, 0.0, 1.0);
float facing = clamp(vFacing, 0.0, 1.0);
float a = clamp(uOpacity * along * along * facing * sqrt(facing), 0.0, 1.0);
gl_FragColor = vec4(uColor * a, a);Hâlâ eksik olanlar
Yol dümdüz: ROAD_Y sabit bir sayı. Kod yorumunda "T1-T2 drop" diye geçen iniş bende yalnızca çizginin şeklinde var. En dar virajlar, 8 birimlik yarıçap sınırı yüzünden gerçeğinden geniş. Gölgeler arabanın çevresindeki tek bir ortografik kutuda hesaplanıyor, uzaktaki ağaçlar hiç gölge düşürmüyor. Işığın ayarları VoxelWorldStage.tsx içinde:
<directionalLight
ref={sun}
position={[14, 22, 10]}
// ...
castShadow={!compact}
// The ortho box is ~32x36 units: 2048 is still ~60 texels a unit, at a quarter of the fill.
shadow-mapSize-width={compact ? 1024 : 2048}
shadow-mapSize-height={compact ? 1024 : 2048}
shadow-camera-left={-16}
shadow-camera-right={16}
shadow-camera-top={18}
shadow-camera-bottom={-18}
// ...
>Sahne büyürse kademeli gölge haritalarına (cascaded shadow maps) geçmem gerekecek, kodda bunun notu duruyor. NaN olayı bir açığı da ortaya çıkardı: yerleşim testleri Node'da çalışıyor, SwiftShader sorunu göstermedi ve gerçek bir GPU'da kareyi kontrol eden otomatik bir adımım yok. Bu yüzden shader'a her dokunduğumda Windows'taki Chrome'da elle bakmam gerekiyor.
- Pisti sürmek için: ana sayfa
- Çizginin kaynağı: Istanbul_park.svg, Wikimedia Commons
- İlk sürümün kullandığı tur: Starter-Kit-Racing
