Browser में open world बनाना, भाग 24: एक दुनिया को सहेजना, और हवा जो आपको दिखे
लेखक Oleg Sidorkin, Cinevva के CTO और सह-संस्थापक
यहाँ नए हैं? series guide का इस्तेमाल करें। यह बताती है कि spike क्या होता है और सभी भागों को link करती है।
भाग 23 ने दुनिया में लोगों को रखा और उन्हें एक आवाज़ दी। यह भाग इस बारे में है कि दुनिया को यह याद रखवाना कि लोगों ने उसके साथ क्या किया, और उसे तब भी जीवंत महसूस कराना जब कोई उसे छू नहीं रहा हो। Spike 47 persistence है: एक creator terrain को आकार देता है और props रखता है, और वे edits एक reload के बाद बच जाते हैं, हर दूसरे peer के साथ sync होते हैं, और जब दो लोग एक साथ edit करें तो साफ़-सुथरे तरीके से फैसला हो जाता है। Spike 49 wind है: एक stylized nature pack के vegetation shaders को port करना ताकि पेड़, झाड़ियाँ और घास उस तरह हिलें जैसा artist ने चाहा था, जो math से ज़्यादा shader compiler के साथ एक लड़ाई बन गई।
एक दुनिया जो याद रखती है
Spike 47 को एक नए tab में खोलें ↗ · Source देखें
सेटअप एक shared authoring session है। Players एक दुनिया में चलते हैं, एक click से props रखते हैं, एक right-click से उन्हें हटाते हैं, और एक brush को drag करके ज़मीन को आकार देते हैं, heightmap को ऊपर-नीचे करते हैं या SDF terrain में volumetric गुफाएँ काटते हैं जो एक chunk को marching cubes तक promote कर देता है। वे जो कुछ भी करते हैं वह एक Cloudflare WorldChunkDO में persist होता है, एक server-authoritative Durable Object जो अपने ही SQLite storage द्वारा backed है। Clients state को सीधे नहीं लिखते। वे intent भेजते हैं, DO फैसला करता है और नतीजा broadcast करता है, और DO ही single source of truth है, इसलिए एक नया जुड़ने वाला एक snapshot पाता है और ठीक उसी दुनिया में उतरता है जो बाकी सब देखते हैं।
दो persistence विवरणों ने अपनी जगह बनाई। Terrain को join पर replay किए जाने वाले event log के रूप में store नहीं किया जाता; इसे per-chunk binary blobs के रूप में store किया जाता है जो source of truth हैं, stroke-end पर upload किए जाते हैं, इसलिए जुड़ने वाला हज़ारों brush samples को फिर से चलाने के बजाय committed bytes को सीधे load करता है। और वे blobs एक बड़ी row के बजाय per chunk एक storage key में रहते हैं, क्योंकि एक अकेला SDF chunk 168 KB का है और DO में per-row limit 2 MB है। एक chunk index यह track करता है कि कौन-सी keys मौजूद हैं ताकि DO जागने पर पूरे map को rehydrate कर सके। Identity को client पर दो storages से संभाला जाता है: player id sessionStorage में रहती है ताकि दो tabs दो अलग peers हों, न कि एक ऐसा peer जो DO के player map में खुद को overwrite कर दे, जबकि display name localStorage में रहता है ताकि एक tab में किया गया rename उन सब में पहुँच जाए।
Concurrent edits को converge कराना
Multiplayer authoring का असली कठिन हिस्सा यह है कि जब दो लोग ठीक एक ही पल में overlapping ज़मीन को edit करें तो क्या होता है। Spike edits को उनके algebra के हिसाब से बाँटता है। Additive operations, heightmap पर raise और lower और SDF पर add और subtract, commutative हैं: उन्हें किसी भी क्रम में लगाने पर वही नतीजा मिलता है, इसलिए वे एक optimistic-stamp path पर चलती हैं जहाँ हर client locally apply करता है और stamp भेज देता है, और DO बिना किसी coordination के उसे सबको broadcast कर देता है। क्रम सचमुच मायने नहीं रखता, इसलिए coordinate करने को कुछ है ही नहीं।
Order-dependent operations, smooth और flatten, दिलचस्प मामला थीं। पहले design ने उन्हें एक region lock दिया था: एक client pointer-down पर एक lock का अनुरोध करता है, DO उसे grant या deny करता है, client press के दौरान samples को buffer करता है, और pointer-up पर DO पूरे stroke को atomically apply कर देता है। यह काम करता है, पर यह अपने ही lock TTL और grant/deny round-trip वाला एक अलग protocol है। जिस साफ़-सुथरे जवाब ने उसकी जगह ली वह एक precomputed delta है: originator smooth या flatten brush को locally चलाता है, फिर per-cell deltas की जो list बनी उसे भेज देता है, और हर peer बस उन deltas को अपने ही cells में जोड़ देता है बिना कुछ भी फिर से derive किए। यह एक order-dependent operation को उसके नतीजे को source पर freeze करके commutative बना देता है, इसलिए पूरा edit system एक ही uniform commutative protocol पर चलता है, समान convergence के साथ और बिल्कुल कोई locks नहीं। Prop locks एक अलग वजह से बने रहते हैं: per-record locking ने owner-only deletion की जगह ली, इसलिए कोई भी peer किसी भी prop को delete कर सकता है जब तक कि किसी ने उसे lock न कर रखा हो, और सिर्फ़ lock करने वाला ही उसे clear कर सकता है। Undo और redo इस तरह काम करते हैं कि client किसी prop की id को रखने से पहले ही उसका नाम तय कर देता है, इसलिए वह server के echo से पहले id को जान लेता है और अपने ही actions को deterministically उल्टा कर सकता है। Hibernating WebSockets एक निष्क्रिय room को पूरे समय मुफ़्त रखते हैं, वही property जिसने पिछले भाग में avatar relay को सस्ता बना दिया था।
Wind को ईमानदारी से port किया, फिर लड़ाई हुई
Spike 49 को एक नए tab में खोलें ↗ · Source देखें
Spike 49 Quaternius का Stylized Nature MegaKit लेता है और उसकी wind को हमारे stack में port करता है। Pack अपने source Godot shaders भेजता है, चार shaders, और सही कदम एक re-invention के बजाय एक ईमानदार translation था। Bark का vertex function खाली है, इसलिए तने rigid हैं; एक पहले की procedural-mask कोशिश तनों को लहरा रही थी, और fix बस यह था कि bark पर wind लगाना पूरी तरह बंद कर दिया जाए। Leaves को एक triangular-pulse hash से per-vertex अव्यवस्थित sway मिलता है, height से masked ताकि canopy हिले और base टिका रहे। Base foliage को world space में एक noise-modulated sin/cos sway मिलता है। Grass base foliage के साथ एक wind-line bobble है, एक power curve के ज़रिए एक scrolling noise texture को sample करती है ताकि texture के सिर्फ़ चमकीले bands ही योगदान दें, जो वे दिखने वाली ripples पैदा करता है जो एक मैदान के आर-पार दौड़ती हैं। Dispatch pack के अपने material-naming convention को मानता है, इसलिए Leaves_Birch नाम का एक material leaves path तक route होता है और Grass_Common grass path तक, कोई अंदाज़ा नहीं।
Port से एक हैरानी यह है कि leaf का रंग texture में नहीं है। Quaternius leaf की दिखावट को पूरी तरह एक vertical gradient और एक Fresnel rim से author करता है: albedo canopy के नीचे एक अतिरिक्त रंग से ऊपर leaf रंग तक का एक mix है, height पर keyed, साथ में emission के रूप में जोड़ा गया एक subsurface-scattering tint जो एक heightFactor vertex attribute पढ़ता है, load time पर world-space Y से per leaf group normalize किया गया, इसलिए gradient और wind mask दोनों सही व्यवहार करते हैं चाहे FBX import ने हर mesh के local axes को कैसे भी rotate किया हो। Godot color constants sRGB tagged हैं और shader के देखने से पहले linear में convert हो जाते हैं, इसलिए port भी वही conversion करता है, बजाय इसके कि चमकीले sRGB numbers को linear मानकर खिलाए और foliage को धो दे।
वह recompile जिसने frame rate खा ली
इसके पहले एक FBX baseline के रूप में ship होने की वजह, पूरे wind material को एक .bak file में अलग रखकर, एक per-frame recompile bug है जिसने scene को लगभग 1 fps तक गिरा दिया। FBX-loaded materials के ऊपर custom TSL को layer करना Three.js को हर frame shader programs फिर से बनाने पर मजबूर कर रहा था, needsUpdate असल में अटका हुआ रहता था। Diagnostic अनुशासन यह था कि हर foliage material को बिना किसी custom nodes वाले एक सादे textured pass तक छील दिया जाए और देखा जाए कि recompile loop बना रहता है या नहीं। अगर वह रुक गया, तो custom graph ही दोषी था; अगर वह चलता रहा, तो वजह FBX material setup या ख़ुद Three.js में, ऊपर की तरफ़ थी। जिस fix ने असली shaders को वापस आने दिया वह यह था कि हर per-material अंतर, leaf color, SSS color, strength, और blend, को uniforms के रूप में bind कर दिया जाए ताकि सारे leaf assets एक ही compiled program साझा करें, बजाय इसके कि compiler हर unique color combination के लिए एक नया shader निकाले और compile queue को thrash करे।
दो और टुकड़े रखने लायक हैं। Grass हर source file के लिए एक InstancedMesh के रूप में render होती है, और उसकी wind world space में compute होती है क्योंकि sin phases world position पर key होते हैं। पर displacement को instance matrix चलने से पहले local space में लगाना पड़ता है, और WGSL में call करने को कोई inverse() नहीं है। translation, एक Y rotation, और एक uniform scale वाले एक instance matrix के लिए, ऊपरी 3×3 का inverse बस उसका transpose है जो scale के वर्ग से भाग किया गया हो, इसलिए shader world displacement को transposed model matrix से गुणा करता है और matrix के पहले column की लंबाई के वर्ग से भाग देता है, बिना किसी square root के scale को वापस पा लेता है। Vertex transform के matrix को फिर से लगाने के बाद, motion ठीक वैसे world space में उतरती है जैसे author की गई थी, हर tuft के rotation या scale से स्वतंत्र। और हर tuft एक per-vertex GPU frustum cull चलाता है: यह instance center को clip space तक project करता है, और अगर वह margin के साथ frustum के बाहर गिरता है, तो हर vertex को local origin तक collapse कर देता है ताकि हर triangle के तीनों vertices एक हो जाएँ, rasterizer उस degenerate triangle को छोड़ देता है, और off-screen grass के लिए कोई fragment, alpha-test, या shadow काम नहीं होता। यह उस coarse per-chunk bounding-sphere cull के ऊपर जुड़ता है जो Three.js पहले से करता है, booleans के बजाय float step से बना ताकि वह सीधे position mix में गुणा हो जाए।
इस अध्याय में संदर्भित तकनीक
Server-authoritative world persistence. एक WorldChunkDO Durable Object prop placement और terrain edits का फैसला करता है, per-chunk binary blobs को source of truth के रूप में persist करता है (इसलिए जुड़ने वाले एक event log को replay करने के बजाय committed bytes load करते हैं), और DO की 2 MB per-row limit के नीचे रहने के लिए per chunk एक storage key store करता है। Player id sessionStorage में रहती है ताकि tabs अलग peers हों; display name localStorage में रहता है ताकि renames tabs के पार पहुँचें।
Commutative edit convergence. Additive terrain ops (raise/lower, SDF add/subtract) commutative हैं और बिना coordination के एक optimistic-stamp path पर चलती हैं। Order-dependent ops (smooth, flatten) को एक region lock हासिल करने के बजाय precomputed per-cell deltas भेजकर commutative बनाया जाता है, इसलिए पूरा system बिना locks के एक ही uniform protocol के तहत converge होता है। Per-record prop locks owner-only deletion की जगह लेते हैं, और client-named object ids server के echo के पहुँचने से पहले deterministic undo/redo को संभव बनाती हैं। देखें GPU-driven LOD।
Godot से TSL में ईमानदार shader porting. Quaternius के चार source wind shaders line-for-line translate होते हैं: rigid bark, height-masked leaf sway, world-space foliage sway, और एक scrolling wind-line bobble वाली grass, pack के material-naming convention से dispatched। Leaf color texture के बजाय एक height gradient और एक Fresnel-driven SSS rim से आता है, और sRGB authoring constants linear में convert होते हैं ताकि look reference renders से मेल खाए।
Per-frame shader recompiles से बचना. FBX materials पर custom TSL को layer करना needsUpdate को pin कर सकता है और हर frame programs फिर से बना सकता है, ~1 fps तक गिर जाता है। हर per-material अंतर को एक uniform के रूप में bind करना सारे variants को एक ही compiled program साझा करने देता है, बजाय इसके कि हर unique parameter set पर एक नया shader निकले। एक plain-textured diagnostic pass यह अलग कर देता है कि वजह custom graph है या ऊपर का setup।
Instanced foliage पर world-space wind. Grass wind world space में compute होती है और एक hand-derived inverse (scale-squared पर transpose) से वापस local में transform होती है क्योंकि WGSL में कोई inverse() नहीं है। एक per-vertex GPU frustum cull off-screen tufts को एक degenerate triangle तक collapse कर देता है ताकि कोई fragment या shadow काम न चले, Three.js के per-chunk bounding-sphere cull के ऊपर जुड़कर।
भाग 24, कुल 29 में से। पिछला: भाग 23 - पचास avatar और कमरे में एक आवाज़ अगला: भाग 25 - एक skeleton, हर outfit Series guide: /hi/blog/2026-02-25-open-world-browser-series-guide