Skip to content

Browser में open world बनाना, भाग 21: एक तेज़ renderer जो तेज़ नहीं निकला

लेखक Oleg Sidorkin, Cinevva के CTO और सह-संस्थापक

यहाँ नए हैं? series guide का इस्तेमाल करें। यह बताती है कि spike क्या होता है और सभी भागों को link करती है।

भाग 20 ने एक सपाट quad पर surface की गहराई का भ्रम बनाया। यह भाग एक render-architecture फ़ैसले के बारे में है, और यही वह spike है जहाँ किताबी जवाब हमारे hardware के लिए गलत निकला। सवाल यह था: third-person camera के नीचे cinematic-घनत्व वाली alpha-tested घास के लिए, 200-player घनत्व तक पहुँचने से पहले क्या हमें एक visibility buffer चाहिए? चलन वाली सलाह ज़ोरदार हाँ कहती है। हमने इसे बनाया, मापा, और जवाब ना था।

वह तकनीक जिसकी हर कोई सिफ़ारिश करता है

Spike 40 को नए tab में खोलें ↗ · View source

एक visibility buffer rendering को दो pass में बाँट देता है। Pass 1 geometry को rasterize करता है और बिना कोई shading किए सिर्फ़ triangle और instance ID एक compact integer target में depth के साथ लिखता है। Pass 2 एक fullscreen pass है जो हर ढके हुए pixel पर ID पढ़ता है, उस triangle के vertices दोबारा fetch करता है, interpolated attributes दोबारा बनाता है, और हर दिखने वाले pixel को ठीक एक बार shade करता है। दावा perfect overdraw rejection का है: depth test उन fragments के खिलाफ़ चलता है जिन्होंने कोई shading काम नहीं किया, इसलिए महँगा material सिर्फ़ उसी पर चलता है जो आप असल में देखते हैं।

यह spike एक ही canvas और एक ही device पर दो रास्ते चलाता है ताकि अकेला variable यह रहे कि shading कहाँ रहता है। Forward रास्ता three.js के ज़रिए एक सामान्य MeshStandardNodeMaterial है। Vis-buffer रास्ता three.js के बाहर चलने वाली एक कच्ची two-pass WebGPU pipeline है, जो three.js की घास का texture सीधे backend से पढ़ता है, pass 1 में एक RG32Uint target में (instanceId, triId) लिखता है और pass 2 में lighting हल करता है। दोनों एक ही ground-truth lighting setup साझा करते हैं।

दो implementation बातें ध्यान रखने लायक हैं। WebGPU में अब भी fragment shaders में कोई portable primitive_index builtin नहीं है, इसलिए तरकीब यह है कि un-indexed geometry पर per-vertex triangle ID bake किया जाए और उसे flat-interpolated पढ़ा जाए, जिसमें vertex count 3× हो जाता है पर 12-vertex घास card पर यह नगण्य है। और three.js के renderer के साथ canvas साझा करना ज़्यादातर कोई बड़ी बात नहीं, जब तक आप कभी context को reconfigure न करें या canvas dimensions को न छुएँ, क्योंकि दोनों three.js के अधीन हैं। Forward रास्ते की timing करना झंझट भरा हिस्सा था, क्योंकि three.js अपने render pass के अंदर GPU timestamp queries डालने का कोई hook नहीं देता; इसका जुगाड़ यह है कि उसके काम को दो no-op timestamp pass से घेर दिया जाए, जो उसके पहले और बाद में submit किए जाते हैं और GPU उन्हें submission क्रम में चलाता है।

आँकड़े उल्टी दिशा में जाते हैं

~1080p पर एक M-series Mac पर, 80 m के मैदान पर cross-card घास की पत्तियों के साथ:

50,000 instances पर vis-buffer रास्ता 25% से जीता, 4.13 ms बनाम forward के 5.51 ms। 100,000 पर यह बराबरी पर रहा। 200,000 instances पर forward 44% से जीता, 5.44 ms बनाम vis-buffer के 7.80 ms। घनत्व बढ़ने के साथ vis-buffer रास्ता अपेक्षाकृत खराब होता जाता है, जो उस लोककथा के बिलकुल उलट है जो कहती है कि यह तभी जीतता है जब overdraw भारी हो।

Forward क्यों टिका रहता है

Apple Silicon GPUs tile-based deferred renderers हैं, और यह पूरा गणित बदल देता है। TBDR पर forward shading में एक hidden-surface-removal चरण होता है जो fragment shader से पहले चलता है: rasterizer एक tile पर map होने वाले हर fragment को इकट्ठा करता है, उन्हें depth के हिसाब से sort करता है, और सिर्फ़ बचे हुए (post-alpha-test) ही fragment shader तक पहुँचते हैं। तो forward रास्ता पहले से ही visibility buffer के "हर pixel पर एक बार shade" वाले वादे का ज़्यादातर हिस्सा मुफ़्त में, hardware के अंदर, चुका रहा है। जैसे-जैसे पत्तियाँ screen पर भरती हैं, कोई shading चलने से पहले HSR पर ज़्यादा fragments reject हो जाते हैं, और forward की असरदार per-pixel लागत overdraw के साथ बढ़ने के बजाय लगभग सपाट बनी रहती है।

Vis-buffer रास्ते के pass 1 को वही TBDR फ़ायदा मिलता है। दिक्कत पूरी की पूरी pass 2 में है। Pass 2 हर pixel का instance matrix एक buffer से पढ़ता है जो 200,000 instances पर 12.8 MB का है, किसी भी GPU cache से कहीं बड़ा। Screen पर पास-पास के pixel आमतौर पर अलग-अलग घास instances के होते हैं (scatter एक jittered grid है, इसलिए पड़ोसी पत्तियों के instance ID मनमाने होते हैं), इसलिए उस buffer से टकराने वाली हर wave divergently cache miss करती है। वह असंगत random access अकेले ही हर frame करीब 4 ms छुपा लेता है। Forward इससे पूरी तरह बच जाता है क्योंकि instance matrix per-instance attribute रास्ते से vertex के साथ ही आ जाता है, तो जब तक fragment shader चलता है तब तक transformed vertex data पहले से tile-local registers में होता है, megabyte-scale random read की ज़रूरत नहीं।

यही वह लागत है जिसे amortize करने के लिए Nanite की material-classification pass मौजूद है: pixels को instance के हिसाब से bin करो और sorted compute waves dispatch करो ताकि हर wave के reads coherent हों। हमारे पास वह नहीं है। एक मोटे अनुमान के मुताबिक pixels को instance के हिसाब से sort करने से वह 4 ms शायद 1.5 से 2 ms तक गिर जाएगा और crossover को 400,000 से 500,000 instances तक धकेल देगा। पर यह एक ऐसी architecture पर optimizations जमा करना है जो यहाँ शुरू से ही जीत नहीं रही।

ईमानदार निष्कर्ष, और वह audit जिसने इसे कमाया

Apple Silicon WebGPU पर alpha-tested cross-card foliage के लिए, three.js की TSL pipeline वाला forward रास्ता पहले से ही vis-buffer लागत के बराबर या उससे नीचे है, और vis-buffer की पूरी plumbing 200,000 instances से काफ़ी आगे तक कुछ भी दिखने लायक नहीं देती और तभी देती है जब आप साथ में एक sorting या binning pass भी जोड़ें। production engine के लिए व्यावहारिक फ़ैसला यह है कि पिछले spikes का forward प्लस LOD प्लस imposter stack बनाए रखें और vis-buffer infrastructure में तब तक निवेश न करें जब तक या तो हम discrete NVIDIA या AMD GPUs को प्रमुख deployment के रूप में न साधें (जहाँ overdraw लागत ज़्यादा linear होती है) या हम एक meshlet architecture पर न चले जाएँ जहाँ vis-buffer वैसे भी स्वाभाविक output है।

चूँकि वह नतीजा अंतर्ज्ञान के उलट है, निष्कर्ष की कोई कीमत तभी है जब तुलना निष्पक्ष हो, इसलिए spike को एक पूरा audit pass मिला। कई असली bug सामने आए और ठीक हुए: एक blade-scale slider जो चुपचाप दोनों रास्तों को desync कर रहा था, आधी forward पत्तियाँ anti-parallel normals की वजह से घुप अँधेरी render हो रही थीं (canonical upward-normal foliage तरकीब से ठीक हुआ), और vis-buffer energy-conserving 1/π के बजाय एक हाथ से चुने Lambert factor, एक hardcoded ambient term, और गायब tone mapping के कारण करीब 2× ज़्यादा चमकीला पढ़ रहा था। fix ने three.js की हूबहू ACES filmic curve को WGSL में copy किया और हर frame असली scene lights से light colors और intensities पढ़ता है। बची हुई जानी-पहचानी कमी, pass 2 में गायब direct specular, तुलना को vis-buffer के पक्ष में झुकाती है, यानी forward सख्ती से हर pixel पर ज़्यादा काम कर रहा है और फिर भी उच्च घनत्व पर जीत रहा है। इससे मुख्य निष्कर्ष आशावादी नहीं, बल्कि conservative बन जाता है। एक चेतावनी टिकती है: यह सब M-series-विशिष्ट है, और एक discrete card पर crossover पलट भी सकता है, इसलिए non-Apple targets के लिए stack पक्का करने से पहले इसे दोबारा चलाना अच्छा रहेगा।

इस अध्याय में संदर्भित तकनीक

Visibility buffer rendering. Pass 1 geometry को rasterize करता है और बिना कोई shading किए सिर्फ़ triangle और instance ID plus depth लिखता है। Pass 2 एक fullscreen resolve है जो हर ढके हुए pixel पर ID पढ़ता है, source triangle दोबारा fetch करता है, perspective-correct barycentric attributes दोबारा बनाता है, और हर दिखने वाले pixel को एक बार shade करता है। चूँकि WebGPU में portable fragment primitive_index नहीं है, triangle ID को un-indexed geometry पर एक flat-interpolated per-vertex attribute के रूप में bake किया जाता है।

TBDR hidden-surface removal बनाम deferred resolve. एक tile-based deferred GPU (Apple Silicon) पर, forward shading fragment shader चलने से पहले ही occluded fragments को reject कर देता है, इसलिए यह visibility buffer के shade-once फ़ायदे का ज़्यादातर हिस्सा मुफ़्त में पकड़ लेता है, और overdraw बढ़ने पर इसकी per-pixel लागत लगभग सपाट रहती है। बजाय इसके एक vis-buffer resolve pass एक बड़े per-instance buffer (200k instances पर 12.8 MB) में असंगत random access की कीमत चुकाता है, जो उच्च घनत्व पर हावी हो जाता है, जब तक कि pixels को पहले instance के हिसाब से sort या bin न किया जाए, जैसे Nanite की material classification करती है।

three.js के WebGPURenderer के साथ canvas साझा करना. कच्चे WebGPU command buffers साझा queue पर three.js submissions के साथ सही ढंग से interleave होते हैं, जब तक आप context.configure() दोबारा कभी न call करें या canvas.width/height न लिखें, क्योंकि दोनों renderer के अधीन हैं। Forward-path GPU timing, जिसके लिए three.js कोई hook नहीं देता, उसके render call के इर्द-गिर्द submit किए गए दो no-op timestamp render passes से घेरी जा सकती है, क्योंकि GPU command buffers को submission क्रम में चलाता है।

एक अंतर्ज्ञान-विरोधी benchmark को validate करना. एक चौंकाने वाला performance नतीजा उतना ही भरोसेमंद होता है जितनी तुलना की निष्पक्षता। दोनों रास्तों को एक जैसे scene content और shading पर audit करना (matched ACES tone mapping, energy-conserving Lambert, एक ही objects से पढ़ी गई lights, एक जैसा blade scale) ही वह चीज़ है जिसने "vis-buffer धीमा है" को एक संभावित measurement artifact से एक टिकाऊ निष्कर्ष में बदला, बची हुई एक असमानता conservative दिशा में झुकी हुई।


भाग 21 / 29। पिछला: भाग 20 - एक सपाट plane पर गहराई का भ्रम अगला: भाग 22 - बादल जिनके बीच से आप उड़ सकते हैं, और culling जो असल में काम आती है Series guide: /hi/blog/2026-02-25-open-world-browser-series-guide