Skip to content

ब्राउज़र में open world बनाना, भाग 16: एक ऐसी structure जो बढ़ते रहने वाली दुनिया को संभाले

लिखा है Oleg Sidorkin ने, Cinevva के CTO और को-फाउंडर

यहाँ नए हैं? सीरीज़ गाइड देखिए। यह बताती है कि spike क्या होता है और सारे भागों के लिंक देती है।

भाग 13 के बाद से हर spike एक ही नुस्खे पर चल रहा था: पिछले monolith को copy करो, एक feature जोड़ो। spike 32 के आते-आते वह monolith एक ही <script type="module"> के अंदर 6,285 लाइनों का index.html बन चुका था। Code search शोरगुल भरा था, किसी feature को कहाँ जोड़ना है यह ढूँढने में उसे लिखने से ज़्यादा वक़्त लगता था, और कोई भी architectural बदलाव एक ऐसी file को छूता था जो इतनी बड़ी थी कि उसे दिमाग में diff करना मुमकिन नहीं था। अगला feature जोड़ने से पहले हमने structure का कर्ज़ चुकाया।

व्यवहार में बिना किसी बदलाव के monolith को तोड़ना

Spike 33 को नए tab में खोलिए ↗ · सोर्स देखिए

शर्त सख़्त थी: हर split एक pure refactor होना चाहिए, redesign नहीं। Monolith 19 .mjs files और एक 151-line वाले host shell में बदल गया। scene, water, grass, character, physics, multiplayer और UI के लिए top-level modules, एक wgsl.mjs जो हर WGSL source string को GPU सच के इकलौते स्रोत के रूप में रखता है, और heightmap, SDF, chunks, GPU buffer wrappers, brush, LOD और persistence के लिए एक terrain/ subtree। कुल code 6,555 लाइनों का निकला, मोटे तौर पर monolith जमा import boilerplate। मात्रा में कोई शुद्ध बदलाव नहीं, navigability में बड़ा बदलाव।

फिर page load हुआ और एक काली स्क्रीन दिखी। दो error messages, दो बिल्कुल अलग root causes। पहला एक zero-byte buffer binding को लेकर WebGPU की शिकायत थी। Monolith में SDF brush buffer तब lazily allocate होता था जब पहला marching-cubes chunk आता था, और bind-group factory संयोग से बाद में चलती थी, buffer के बन जाने के बाद। terrain/gpu.mjs को terrain/brush.mjs से अलग करने पर module evaluation का क्रम बदल गया, इसलिए अब factory पहले चली और एक null placeholder को bind करने की कोशिश की। इसका हल था bind-group creation को पहले dispatch तक टाल देना, एक getOrCreateBindGroup(chunk) helper के ज़रिए। "सब कुछ पहले से बना लो" वाला pattern monolith के इकलौते init path की एक देन था।

दूसरा message एक डरावना दिखने वाला FBX skeleton warning था जो असल में एक झाँसा निकला। यह spike 25 से छप रहा था और बेज़रर था। Character सिर्फ़ इसलिए गायब था क्योंकि पहले bug का सिलसिला चल पड़ा: हर compute dispatch fail हुआ, heightmap कभी लिखा ही नहीं गया, height samples 0 लौटाने लगे, और character origin पर spawn होकर दुनिया के बीच से नीचे गिर गया। Buffer ठीक करो, तो character ठीक से animate करता है, warning समेत।

यही इस refactor का असली सबक है। Monolith ने हर "X को Y बनने से पहले मौजूद होना चाहिए" वाले रिश्ते को top-to-bottom script order के अंदर छुपा रखा था। Modularize करने पर वह क्रम बदल गया और तीन और छुपे हुए ordering bugs सामने आए: heightmap upload से पहले grass scatter होना, environment map के decode पूरा होने से पहले water plane जुड़ना, और पहले frame के बाद persistence load पूरा होना। तीनों एक-लाइन के fixes थे, और split के बिना इनमें से एक भी नहीं पकड़ा जाता।

बिना किसी चीज़ को बढ़ाए सौ props

Spike 34 को नए tab में खोलिए ↗ · सोर्स देखिए

Spike 34 इस बात की परीक्षा थी कि structure का फ़ायदा मिला या नहीं। मक़सद था एक first-person palette, जिससे एक CC0 model pack से trees, rocks, bushes, mushrooms और paths रखे जा सकें, terrain-aware alignment, persistence, multiplayer sync और physics colliders के साथ, और यह सब controls छोड़े बिना। नए code की हर लाइन src/props/ के नीचे पाँच नई files में आई, और कोई भी मौजूदा module दस hook-up लाइनों से ज़्यादा नहीं बढ़ा।

Asset वाला सफ़र एक ऐसा चक्कर ले बैठा जो दर्ज करने लायक है, क्योंकि यह उस तरह की चीज़ है जो पूरा दिन खा जाती है। हमने Quaternius के Ultimate Nature pack से शुरुआत की, एक FBX library जिसमें embedded textures नहीं थीं। FBX materials बिना map के MeshPhong के रूप में आए, इसलिए हमने एक manual material-name-to-PNG table जोड़ी, Phong को Standard में बदला, और color spaces हाथ से set किए। करीब 30% materials का कोई matching PNG नहीं था और कई नाम एक जैसे trees के बीच confusing थे। एक दूसरे FBX pack में भी वही कमी थी। हल और mapping tables नहीं था, हल एक बेहतर तरीके से authored pack था: Quaternius का Stylized Nature MegaKit 116 पूरे glTFs के साथ आता है, जिनमें embedded PBR materials और baked normals हैं। FBXLoader की जगह GLTFLoader लाने से cm-to-meters scaling, texture table और Phong conversion मिट गए, और library.mjs करीब 80 लाइन छोटा हो गया। सीख यह: जो CC0 packs glTF के साथ PBR भेजते हैं, उनके लिए वही सही pipeline है, और manual texture mapping वाला FBX दोगुना code था आधी quality के लिए।

glTF के रास्ते के साथ कुछ नुकीले किनारे भी आए। Palette 116 thumbnails render करती है, और WebGPU का canvas.toDataURL() एक GPUCanvasContext surface के लिए खाली लौटाता है, इसलिए thumbnails एक RenderTarget में render होती हैं, readRenderTargetPixelsAsync से वापस पढ़ी जाती हैं, और एक 2D canvas में blit होती हैं, WebGPU के 256-byte row alignment का ध्यान रखते हुए। Ghost previews हर material को clone करके उन्हें हरा रंग देती हैं, जो उन meshes पर टूट गया जिनका material एक array है, और इसे एक Array.isArray branch से ठीक किया गया। और जो 16-bit normal maps ~200 MB पर आए थे, उन्हें एक बार के mogrify -depth 8 से ~32 MB तक घटाया, और वे देखने में एक जैसे ही रहे, क्योंकि browsers वैसे भी upload पर downsample कर देते हैं।

जब rendered geometry सिर्फ़ GPU पर मौजूद हो

सबसे सिखाने वाला bug यह था कि cursor के हिलने पर ghost preview 1 से 2 meter की छलाँगों में snap कर रहा था। Terrain meshes अपने vertex positions एक StorageBufferAttribute में रखते हैं क्योंकि compute pipeline उन्हें सीधे GPU पर लिखता है, इसलिए three.js का CPU Raycaster उन्हें देख नहीं पाता और कुछ नहीं लौटाता। Fallback analytic heightmap के ख़िलाफ़ एक मोटा 1.5 m ray-march था, और वही तय stride वह grid था जो user को दिखता था। हमने उसकी जगह एक adaptive march लगाया: surface से काफ़ी ऊपर रहते हुए stride 2.5 m, उसके 5 m के भीतर आने पर घटकर 0.4 m, और फिर (rayyterrainy) का चिह्न पलटने पर 14 बार bisect। इससे प्रति cast करीब 30 चौड़े steps जमा 14 bisections में sub-millimeter precision मिलती है। जब rendered geometry सिर्फ़ GPU पर बसती हो, raycaster से मत लड़िए, analytic source के ख़िलाफ़ march कीजिए।

ऐसे colliders जो edits के दौरान ईमानदार रहें

हमने convex hulls या mesh colliders के बजाय primitive proxies चुने। Quaternius props गोल-मटोल और low-poly हैं, बिना किसी मायने रखने वाली concavities के, इसलिए hulls उसी gameplay के लिए करीब 50 गुना code और 10 गुना runtime cost होते। हर prop अपने bounding box से निकले एक shape में सिमट जाता है: trees और cacti एक vertical capsule में, rocks एक sphere में, logs लंबी axis के साथ एक horizontal capsule में, और सजावटी bushes और flowers किसी चीज़ में नहीं। Rocks और logs walkable हैं (सिर्फ़ vertical push, ताकि आप उन पर खड़े हो सकें), trees और cacti blocking हैं (पूरा 3D push, ताकि आप किसी trunk पर चढ़ न सकें)। एक 8 m spatial hash प्रति-frame test को player के 3×3 neighborhood तक सीमित रखता है, आम तौर पर शून्य से छह props तक।

दो design फ़ैसलों ने system को संगत रखा। Terrain alignment code में hard-code किया हुआ कोई category enum नहीं, बल्कि एक manifest flag है, इसलिए ghost preview और committed placement एक ही placement.alignToTerrain value पढ़ते हैं और आपस में असहमत नहीं हो सकते। और रखे गए props terrain edits पर एक helper के ज़रिए react करते हैं: किसी brush stroke के बाद (local हो या किसी peer से replay हुआ), refreshPlacementsInRadius प्रभावित disc के अंदर हर prop के नीचे की ज़मीन फिर से sample करता है, alignment दोबारा लगाता है, और collider endpoints दोबारा निकालता है। किसी tree के नीचे एक पहाड़ी sculpt कीजिए और tree उसके साथ ऊपर चढ़ जाता है। Persistence और multiplayer spike 31 के pattern को हूबहू दोहराते हैं, {uid, propId, x, y, z, rotY, scale} की एक flat list रखते हैं और place, remove तथा nudge events को BroadcastChannel पर mirror करते हैं।

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

WebGPU के अंदर ES module decomposition। एक monolithic <script type="module"> को bare-path .mjs imports में तोड़ने के लिए किसी bundler की ज़रूरत नहीं होती, जब modules static assets के रूप में serve होते हैं, और three.js TSL module boundaries के आर-पार ठीक चलता है। छुपी हुई लागत है initialization order: एक monolith "Y से पहले X बनाओ" को top-to-bottom script order में दर्ज करता है, जबकि modules import order में evaluate होते हैं, जो किसी GPU bind-group factory को उसके buffer के मौजूद होने से पहले चला सकता है। Fix का pattern है lazy initialization (पहली बार इस्तेमाल पर getOrCreate...) और declaration order पर भरोसा करने के बजाय सही promise का इंतज़ार करना।

embedded PBR वाला glTF बनाम manual mapping वाला FBX। glTF meters में आता है, अपनी ही textures को reference करता है, और सीधे MeshStandardMaterial देता है, इसलिए glTF के रूप में authored कोई CC0 pack सीधा PBR pipeline में गिर जाता है। texture-binding metadata के बिना FBX packs को एक हाथ से संभाली गई material-name-to-PNG table चाहिए जो हर pack update पर खिसक जाती है, साथ में एक Phong-to-Standard conversion और manual color-space tagging। एक foliage safety net बिना alphaTest वाले transparent materials को alphaTest: 0.5 cutout cards में बढ़ा देता है ताकि वे opaque geometry के पीछे ठीक से sort हों।

WebGPU offscreen thumbnails। canvas.toDataURL() किसी GPUCanvasContext-backed canvas के लिए खाली लौटाता है क्योंकि presentation surface से वापस किसी 2D context तक कोई रास्ता नहीं है। एक RenderTarget में render करना, readRenderTargetPixelsAsync से pixels पढ़ना, और एक 2D canvas में blit करना काम करता है, जब तक कि blit WebGPU के 256-byte-aligned read-back stride पर चले। नतीजे localStorage में एक version-bumped key के नीचे cache होते हैं ताकि pack बदलने पर पुराने renders रद्द हो जाएँ।

analytic heightmap के ख़िलाफ़ adaptive ray-march। जब terrain vertices एक GPU StorageBufferAttribute में बसते हैं, तो CPU raycaster उन्हें नहीं देख पाता। analytic height function के साथ march करना, surface से दूर बड़े stride के साथ, उसके पास छोटे stride के साथ, और (rayyterrainy) के चिह्न पलटने पर एक binary-search refinement के साथ, samples की एक सीमित संख्या में sub-millimeter cursor precision देता है। यही primitive brush cursor और prop ghost दोनों को चलाती है।

spatial hash के साथ primitive capsule colliders। हर prop अपने bounding box से एक category-derived capsule या sphere में सिमटता है, {kind, walkable, radius, p1, p2} के रूप में दर्ज होता है, और हर 8 m hash bucket में register होता है जिससे वह overlap करता है। प्रति frame player सिर्फ़ अपने 3×3 bucket neighborhood के props test करता है, हर एक के लिए एक capsule-vs-capsule resolution। Walkable proxies (rocks, logs) को सिर्फ़ vertical push मिलता है, blocking proxies (trees) को पूरा 3D push। जिस capsule गणित पर यह टिका है उसके लिए SDF terrain collisions देखिए।


29 में से भाग 16। पिछला: भाग 15 - Baseline बदलिए, फिर उसे sync कीजिए अगला: भाग 17 - वे animations जिन्हें retargeting की ज़रूरत नहीं पड़ी, और एक live asset search सीरीज़ गाइड: /hi/blog/2026-02-25-open-world-browser-series-guide