Browser में open world बनाना, भाग 20: एक सपाट plane पर गहराई का भ्रम
लेखक Oleg Sidorkin, Cinevva के CTO और सह-संस्थापक
यहाँ नए हैं? series guide का इस्तेमाल करें। यह बताती है कि spike क्या होता है और सभी भागों को link करती है।
भाग 19 ने दूरी पर पूरे पेड़ का भ्रम बनाने के लिए एक सपाट quad का इस्तेमाल किया। यह भाग पास से गहराई का भ्रम बनाने के लिए एक सपाट quad का इस्तेमाल करता है: parallax occlusion mapping, वह तरकीब जो एक cobblestone सड़क को ऐसा दिखाती है जैसे उसमें 5 cm की धंसी हुई grouting हो, बिना एक भी अतिरिक्त vertex खर्च किए। मकसद यह था कि इसे production stack (Three.js r184, WebGPU, TSL) पर उतारा जाए ताकि terrain detail materials उस गहराई के भ्रम को वहाँ ले जा सकें जहाँ वह मायने रखता है और बाकी हर जगह सपाट-texture जितनी ही लागत चुकाएँ।
गहराई का भ्रम बनाने के तीन तरीके, अगल-बगल
Spike 39 को नए tab में खोलें ↗ · Source देखें
यह spike तीन सपाट 5×5 m planes अगल-बगल रखता है, सभी का material scaffold एक जैसा, फर्क सिर्फ उस UV में जो samplers को feed करता है। Flat texture को सीधा sample करता है, यह reference baseline है। Single-sample parallax UV को view direction के साथ एक बार उस बिंदु की height के हिसाब से shift करता है, जो सस्ता है और कम amplitude पर ठीक है पर grazing angles पर तैरने लगता है। POM tangent space में ray-march करता है: view ray के साथ step बढ़ाओ, वह पहली layer ढूँढो जहाँ ray heightfield के नीचे जाती है, और crossing को परिष्कृत करो। Tangent space सरल बना रहता है क्योंकि हर test plane axis-aligned है, इसलिए view direction पूरे per-vertex TBN matrix की जगह बस कुछ sign flips के साथ tangent space में पैक हो जाती है। Texture set live Polyhaven file API से खींचता है, वही path जो भाग 17 की model search ने इस्तेमाल किया था।
दो WebGPU दीवारें, और एक branchless ray-march
किताबी POM loop पहली crossing पर search से बाहर निकल जाता है। r184 पर वह काम नहीं करता, दो अलग वजहों से। If(...).and(...) बिना errors के compile हुआ पर ऐसा WGSL पैदा किया जहाँ loop body कभी चली ही नहीं, इसलिए post-loop refinement कचरे पर चला और plane लगभग सफेद render हुआ। और standalone node के तौर पर Break() r184 build में आया ही नहीं था, इसलिए काम करने वाले If के साथ भी "पहली crossing पर रुको" कहने का कोई तरीका नहीं था। दोनों इस version range में If और Loop सीमाओं के पार TSL control flow के over-optimizing से जुड़े जाने-माने three.js issues तक जाते हैं।
Rewrite branchless है। हर iteration बिना शर्त texture sample करता है, जो texture access को uniform control flow में रखता है जैसा WGSL spec चाहता है, फिर नई state को एक float के रूप में रखे done flag के जरिए मोड़ देता है। एक बार done 1 पर पलटता है, तो per-iteration mix calls "state को अपरिवर्तित रखो" में degenerate हो जाती हैं, जो break का branchless समकक्ष है। done flag एक step helper से बनता है जिसे 0.5 + 0.5 × sign(x + ε) के रूप में लागू किया गया है क्योंकि boolean-to-float coercion r18x line में अनिश्चित रहा है और sign() हर जगह सुरक्षित है। लागत यह है कि हर fragment सभी 64 iterations चलाता है चाहे वह असल में कहीं भी cross करे, पर fragment scale पर यह सही सौदा है: runtime वैसे भी max steps पर gate करता है, और एक असली GPU भी एक "असली" break के पार speculate कर लेता। यहाँ एक साफ fallback मायने रखता है, एक अंतिम mix(baseUV, refined, done), ताकि zero amplitude पर (distance fade के दूर वाले छोर पर) कोई fragment cross न करे, done 0 रहे, और POM material सपाट से bit-identical हो। यही तो distance-LOD तरकीब का पूरा मकसद है: जहाँ effect वैसे भी sub-pixel है वहाँ सपाट लागत में collapse कर दो।
Bug गणित की चूक नहीं, अनुशासन की चूक थी
Branchless version चला पर विकृत दिखा, मध्यम amplitude पर streaky horizontal artifacts और कम amplitude पर सूक्ष्म-रूप-से-गलत-पर-साफ-नहीं नतीजा। Fix एक एक-line prompt से आया: जाओ canonical reference पढ़ो। जिस LlamAcademy tutorial ने इसे प्रेरित किया वह बस एक Unity ShaderGraph node है, इसलिए असली implementation Unity के PerPixelDisplacement.hlsl में रहता है। उसे line दर line पढ़ने से तीन semantic फर्क सामने आए जो मैंने अनजाने में डाल दिए थे: ray-height baseline में एक off-by-one (Unity loop से पहले एक initial advance करता है, इसलिए मेरा frame of reference पूरे एक step से phase से बाहर था, करीब आधे समय crossings को गलत layer में उतार रहा था), max offset पर एक sign convention जिस पर refinement step निर्भर करता है, और एक cumulative-offset बनाम cumulative-UV bookkeeping चुनाव जिसने मेरे refinement गणित को ज्यादा मेहनत करवाई और sign को उलझा दिया।
मूल वजह कोई एक error नहीं थी, वह दो references को मिलाना थी। मैंने LearnOpenGL POM tutorial को अपना guide बनाया था, जो मिलते-जुलते पर अलग sign conventions और एक अलग refinement formula इस्तेमाल करता है, और एक mongrel हालत में जा पहुँचा जहाँ दो-तिहाई गणित एक source से मेल खाता था और एक-तिहाई दूसरे से। Rewrite Unity के HLSL का TSL में लगभग verbatim port है, वही variable names, वही initial advance, वही refinement, ऊपर branchless done flag रखे हुए। यह सबक साथ ले जाने लायक है: जब आप किसी और stack से एक known-good shader port करें, तो पहले उसे उन्हीं names के साथ line-दर-line port करो, फिर local style के लिए refactor करो। Port के बीच में किसी दूसरे reference के खिलाफ दोबारा derive मत करो।
एक reference plane जो झूठ नहीं बोल सकता
Side-by-side में स्पष्ट चीज़ गायब थी: एक real-geometry plane। उसके बिना "POM काफी अच्छा दिखता है" अप्रमाणनीय है। किसके मुकाबले अच्छा? तो spike ने एक चौथा plane जोड़ा, वही heightmap असली vertex positions के जरिए धकेला हुआ। WebGPU में hardware tessellation नहीं है (यह spec में है ही नहीं, Metal compatibility के लिए हटा दिया गया), इसलिए विकल्प एक सघन-रूप से subdivided plane है (256×256 segments, 131,072 triangles) जिसमें vertex stage में vertex displacement है। वही amplitude uniform POM और geometry plane दोनों को चलाता है, इसलिए वे साथ fade होते हैं और तुलना हर दूरी पर apples-to-apples बनी रहती है।
Screen पर ground truth होने से गुणात्मक दावे मापने योग्य हो गए। नीचे देखते हुए 16° orbit पर, POM और tessellated plane internal shading पर सहमत हैं। Grazing angles पर वे ठीक वहीं अलग होते हैं जहाँ उन्हें होना चाहिए: POM geometry के बिल्कुल सीधे rectangular edge पर clamp होता है, जबकि असली mesh असली peaks और valleys का एक उबड़-खाबड़ horizon profile दिखाता है जो रोशनी पकड़ता है। तो POM का edge "तैरना" अब साबित होकर algorithm में अंतर्निहित है, texture या lighting का artifact नहीं। दोनों लागत के आकार भी साफ हैं: POM fragment-bound है (लागत covered pixels के साथ बढ़ती है), tessellated plane vertex-bound है (लागत coverage की परवाह किए बिना mesh density के साथ बढ़ती है)। एक terrain chunk के लिए, जो पहले से एक heightmap-driven plane की vertex लागत चुकाता है, sub-mesh detail के लिए POM सही जवाब है।
Reference plane ने एक सूक्ष्म UX bug भी पकड़ा। User ने गौर किया कि amplitude बढ़ने पर surface धँसता दिखता था। यह Unity की उस convention तक जाता है जो geometric plane को heightfield के top के रूप में मानती है, इसलिए peaks flush anchor होते हैं और बाकी सब नीचे की ओर parallax होता है, औसत surface को सपाट baseline से (1 − mean_h) × amplitude जितना नीचे खींचते हुए। Fix convention को फिर से center करता है ताकि h = 0.5 plane हो, peaks camera की ओर उठें और valleys धँसें। Algorithm ठीक वैसे ही चलता है जैसे Unity बताता है; spike बस output को आधे offset से post-process करता है ताकि slider खींचने वाले इंसान के लिए "amplitude" का जो मतलब होना चाहिए उससे मेल खाए।
एक और चीज़ reference plane ने सुलझाई। एक "Steps" slider कुछ करता नहीं दिखता था, जो एक plumbing bug जैसा लगता था पर था नहीं। linear search के बाद का तीन-iteration secant refinement इतना अच्छा है (Tatarchuk के 2006 के POM paper में लिखा है कि 4-step search plus 3-step secant नज़र से एक 64-step search से अलग नहीं किया जा सकता) कि एक smooth heightmap पर 4 से 64 तक हर step count उसी sub-texel UV पर converge हो जाता है। Fix एक toggle था, re-plumbing नहीं: secant बंद करो और step slider crossing precision पर एकमात्र control बन जाता है, इसलिए 4 पर गिराने से cobblestone साफ-साफ stair-step करता है और 64 पर चढ़ाने से वापस smooth हो जाता है। Toggle एक 0/1 uniform है जो बंद होने पर हर secant state update को no-op में mix कर देता है, इसलिए toggle करने से material कभी rebuild नहीं होता और कभी stutter नहीं करता।
इस अध्याय में संदर्भित तकनीक
TSL में parallax occlusion mapping. POM tangent space में एक heightfield के बीच से view direction पर ray-march करता है, वह पहली layer ढूँढता है जहाँ ray surface के नीचे गिरती है, और crossing को परिष्कृत करता है, बिना अतिरिक्त geometry के एक सपाट quad पर धँसी-mortar गहराई पैदा करता है। एक अंतिम mix(baseUV, refined, done) material को सपाट से bit-identical बना देता है जब कोई fragment cross नहीं करता, और यही वह चीज़ है जो distance-LOD amplitude attenuation को दूरी पर लागत को सपाट-texture लागत में collapse करने देती है। देखें terrain materials।
WebGPU control flow के लिए branchless loops. Three.js r184 पर, TSL If(...).and(...) ऐसे WGSL में compile हो सकता है जिसकी loop body कभी चलती नहीं, और standalone Break() उपलब्ध नहीं है। Portable pattern है हर iteration में एक बिना शर्त texture sample (WGSL spec के अनुसार texture access को uniform control flow में रखना) plus एक float के रूप में रखा done flag जो set होने पर हर state update को no-op में mix कर देता है। sign(x + ε) से बना एक step helper अविश्वसनीय boolean-to-float coercion से बचाता है। लागत early-out बिंदु की परवाह किए बिना constant max iterations है, fragment scale पर सही सौदा।
Verbatim shader porting. किसी और engine से एक known-good shader port करना पहले original के variable names के साथ line-दर-line होना चाहिए, local style के लिए refactor दूसरे नंबर पर। दो references मिलाने (Unity का PerPixelDisplacement.hlsl और LearnOpenGL tutorial) ने एक mongrel पैदा किया जिसमें एक off-by-one ray baseline, एक उल्टा offset sign, और एक refinement formula थी जिसका clamp out-of-range weights को spatial discontinuities के रूप में छिपा देता था। एक canonical ground truth, दोबारा-derivation नहीं।
Vertex-displaced ground-truth reference. WebGPU में hardware tessellation न होने पर, vertex stage में displaced एक सघन-रूप से subdivided plane (256² segments) एक fragment-stage भ्रम को validate करने के लिए असली geometry के तौर पर खड़ा होता है। दोनों को एक ही amplitude uniform से चलाना तुलना को दूरी भर ईमानदार रखता है। POM fragment-bound है (covered pixels के साथ बढ़ता है) और geometry plane vertex-bound है (mesh density के साथ बढ़ता है), इसलिए वे ठीक silhouette edges पर अलग होते हैं, साबित करते हुए कि POM का edge swim अंतर्निहित है, artifact नहीं।
29 में से भाग 20। पिछला: भाग 19 - वह imposter जिसे एक जंगल झेलना है अगला: भाग 21 - एक तेज़ renderer जो तेज़ नहीं था Series guide: /hi/blog/2026-02-25-open-world-browser-series-guide