Skip to content

ब्राउज़र में open world बनाना, भाग 13: Terrain sculpting और math function की मौत

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

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

बारह भागों तक हमने एक terrain engine बनाया जिसे आप देख सकते थे। उसके ऊपर उड़ सकते थे। seams को न फटते देख तारीफ़ कर सकते थे। इस बार हम उसे छूना चाहते थे।

लक्ष्य आसान लग रहा था: एक player को real time में brush से terrain sculpt करने दो, और उन सिस्टम्स में से किसी को न तोड़ो जिन्हें बनाने में हमने 24 spikes लगाए। इसमें तीन कोशिशें लगीं, दो ऐसे bugs जो rendering failures जैसे दिखते थे पर असल में data model failures थे, और इस पर एक बुनियादी फिर से सोच कि terrain data को कैसे काम करना चाहिए।

तीन झूठी शुरुआतें

Spike 25 आसान वाला होना चाहिए था। production codebase में पहले से एक raycaster था जो terrain meshes को hit करता है। placement tool उसे objects गिराने के लिए इस्तेमाल करता है। एक brush tool उसी shape को follow करता है, बस वह heightmap values को बदलता है, prefab spawn करने के बजाय। आसान।

कोशिश एक: मैंने इसे सीधे production world/client/ TypeScript code में बनाया। नया terrain-brush.ts, chunk.ts में बदलाव, protocol changes, Vue component updates. एक घंटे के भीतर मेरे पास एक brush था जो कुछ-कुछ काम करता था, पर chunk boundaries पर दिखने वाली normal discontinuities थीं। मैं बता नहीं सकता था कि bug मेरे brush code में था, मौजूदा chunk stitching में, या full render loop के साथ किसी interaction में। यह ठीक वही स्थिति है जिसे रोकने के लिए spike methodology मौजूद है। मैंने नियम छोड़ दिया था और तुरंत उसकी कीमत चुकाई। सब कुछ revert कर दिया।

कोशिश दो: standalone spike, पर मैंने Three.js 0.170.0 और WebGL की तरफ़ हाथ बढ़ाया। production code WebGL इस्तेमाल करता है, तो यह natural लगा। पर Spikes 13-24 सब WebGPU पर शिफ़्ट हो चुके थे। एक WebGL brush spike बनाना यह साबित करता कि वह legacy renderer पर काम करता है, उस वाले पर नहीं जिसकी तरफ़ हम migrate कर रहे हैं। ग़लत दिशा। फिर से शुरू।

कोशिश तीन: WebGPU, WebGPURenderer, vertex generation के लिए compute shader, Spike 22 stack से मेल खाता हुआ। इस बार architecture सही था। पाँच brush operations काम कर रहे थे: raise, lower, smooth, flatten, noise. M1 पर brush cycle P95 4ms से कम।

और seam bug अब भी वहीं था।

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

वह seam bug जो मरता ही नहीं था

cross-chunk normal computation का standard fix है border overlap: हर chunk अपने neighbors से data का एक extra ring स्टोर करता है, ताकि boundary पर normal calculation दोनों तरफ़ से sample कर सके। मैंने वह किया। neighbor edge data को एक expanded buffer में copy किया। seams फिर भी फटते रहे।

मैंने math में गहराई से देखा। chunk A (cx=-1) और chunk B (cx=0) के बीच की boundary पर, दोनों chunks को shared vertex पर एक ही normal compute करनी थी। chunk A के shader ने mix(own_col31, own_col32, 0.85) sample किया। chunk B ने mix(neighbor_edge, own_col0, 0.85) sample किया। ये अलग-अलग data के ज़रिए अलग-अलग bilinear interpolation paths हैं। सही border data के बावजूद, दोनों chunks एक ही point के लिए अलग normals compute करते हैं।

यही वह पल था जब मुझे समझ आया कि असली bug border copy नहीं था। असली bug data model था।

1 से 24 तक का हर spike height_at() नामक एक procedural math function इस्तेमाल करता था। उसे world coordinates दो, height पाओ। साफ़, global, stateless. brush एक math function को बदल नहीं सकता था, इसलिए मैंने ऊपर एक displacement buffer जोड़ा था। terrain अब height_at(x,z) + displacement[i] था। GPU shader में base terrain के लिए 30 lines के noise functions plus displacement overlay के लिए bilinear interpolation code embedded था। flatten brush को यह पता करने के लिए height_at() घटाना पड़ता था कि कौन-सी displacement value target height बनाएगी। दो सिस्टम, एक के ऊपर एक रखे हुए, अलग sampling strategies के साथ अलग चीज़ें compute करते हुए।

असली game ऐसे काम नहीं करता। production में, authored terrain buffers में स्टोर किया गया sampled data होता है। procedural function शुरुआती spikes से एक सुविधाजनक stand-in थी। उसने अपना काम कर दिया था। अब वह सक्रिय रूप से bugs पैदा कर रही थी।

मैंने उसे मार दिया।

अब हर chunk के पास असली height values वाला एक heightmap Float32Array है। creation पर, procedural noise उसे भरता है। उसके बाद, noise function फिर कभी call नहीं होता। brush stored heights को सीधे बदलता है। GPU shader एक buffer से एक function का इस्तेमाल करके पढ़ता है: hm_at(i,j). Normals उसी data पर grid-aligned central differences इस्तेमाल करते हैं। कोई bilinear interpolation ambiguity नहीं। कोई two-system mismatch नहीं। shader 90 lines से 40 lines पर आ गया।

seams ख़ुद ठीक हो गए। shared edge पर दोनों chunks अब अपने-अपने buffers से एक ही discrete height values पढ़ते हैं (border overlap में सही neighbor interior points के साथ)। एक ही data अंदर, एक ही normals बाहर।

यह brush का सबक़ नहीं था। यह data architecture का सबक़ था जिसे brush ने उजागर कर दिया।

फटता हुआ mesh

Spike 26 इसका volumetric समकक्ष था। एक 64-cubed SDF volume को brush से बदलो, फिर marching cubes से re-mesh करो। Spike 25 जैसा ही सवाल, पर 3D में।

जब मैंने इसे पहली बार चलाया, mesh फट गया। हर दिशा में लंबे spikes निकल रहे थे, जैसे एक sea urchin का दिन ख़राब चल रहा हो।

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

मैंने जो MC case table generate की थी उसमें 4096 के बजाय 3840 entries थीं। पूरी table है 256 cases×16 slots=4096, पर मेरे पास 256×15=3840 थीं, case 112 से शुरू होकर सोलह missing rows. उस index के बाद हर lookup shift हो गई, इसलिए case number अब triangulation data से मेल नहीं खाता था। जब marching cubes ग़लत entry पढ़ता है, तो वह ऐसी edges बनाता है जहाँ दोनों endpoints surface के एक ही तरफ़ होते हैं। असली crossing edge पर interpolation parameter

t=vavbva

[0,1] में आता है क्योंकि va और vb के signs उल्टे होते हैं। एक fake edge पर उनके signs एक जैसे होते हैं, इसलिए vbva शून्य के पास होता है या sign पलट देता है और t [0,1] के बाहर निकल जाता है, vertex को volume से काफ़ी दूर रखते हुए। इसे कुछ सौ ग़लत cells से गुणा करो और तुम्हें एक hedgehog मिल जाता है।

fix बेवक़ूफ़ी की हद तक आसान था: Spike 12 से proven table byte-for-byte copy कर लो। सबक़ सीखा। जब एक proven copy मौजूद हो तो lookup table कभी दोबारा generate मत करो।

दूसरा bug ज़्यादा सूक्ष्म था। smooth brush को terrain features को नरम करना था। उसके बजाय उसने तेज़ creases बना दिए। समस्या: मैं हर SDF value को शून्य (isosurface) की तरफ़ खींच रहा था। यह सुनने में लगता है कि इससे चीज़ें smooth होनी चाहिए, पर इससे distance field collapse हो जाता है। surface के ऊपर और नीचे दोनों के voxels शून्य की तरफ़ दौड़ते हैं, brush radius में सब कुछ flatten करते हुए। boundary पर, smoothed voxels एक hard step के साथ unsmoothed voxels से मिलते हैं। "smooth" brush एक crease generator था।

fix था proper Laplacian smoothing. हर value को शून्य की तरफ़ खींचने के बजाय, उसे अपने 6 direct neighbors के average की तरफ़ खींचो:

ϕiϕi+λ(16jN(i)ϕjϕi)

कोष्ठक में जो term है वह एक discrete Laplacian है, और λ(0,1] smoothing strength है। यह पास की geometry का average लेता है, surface shape को नरम करता है जबकि distance field gradient को collapse करने के बजाय बनाए रखता है।

सब कुछ एक साथ

Spike 27 integration gate था। Spike 24 की पूरी pipeline (heightmap patches, MC chunks, Transvoxel seams, geomorph LOD) लो और उसे Spike 25 के sampled data model और दोनों brush types के साथ मिलाओ।

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

सबसे पहले मैंने हर shader से height_at() को निकाल फेंका। तीनों compute shaders (SDF fill, heightmap patch, Transvoxel seam) अब एक ही 129x129 heightmap GPU buffer से bind होते हैं और एक shared WGSL preamble के ज़रिए एक ही hm_sample() bilinear interpolation function इस्तेमाल करते हैं। एक data source, कई consumers. वो procedural noise functions जो Spike 1 से हर shader में रहे थे, ख़त्म हो गए।

फिर दिलचस्प समस्याएँ शुरू हुईं।

जब एक SDF brush किसी chunk को MC mode में lock करता है, तो उस chunk और उसके heightmap neighbor के बीच के Transvoxel seam को heightmap नहीं, बल्कि SDF volume से sample करना होता है। मैंने seam shader को अतिरिक्त storage buffer bindings और per-chunk MC flags के साथ बढ़ाया। संभालने के लिए चार boundary combinations: HM-HM, HM-MC, MC-HM, MC-MC.

LOD एक और पहेली थी। पहले के spikes में, किसी MC chunk को lower LOD पर switch करने का मतलब था SDF को एक coarser resolution पर फिर से भरना। मैंने उसे stride-based sampling से बदल दिया: SDF data full resolution (65 grid points) पर रहता है। MC shader grid size और cell count के ratio से एक stride compute करता है। LOD0 पर stride 1 है। LOD1 पर stride 2 है, हर दूसरे voxel को sample करते हुए। chunks अपने SDF data को छुए बिना आज़ादी से LOD बदल सकते हैं।

सबसे संतोषजनक fix था dynamic vertical chunk spawning. किसी chunk के top से ऊपर तक sculpt करो, और उसके ऊपर एक नया MC-only chunk दिखाई देता है जिसका SDF नीचे के chunk के boundary face से initialize होता है। नीचे की तरफ़ sculpt करो, वही बात। world edits के हिसाब से बढ़ता है।

आख़िरी गोचा यह था कि heightmap brush MC-locked chunks के साथ चुपचाप कुछ नहीं करता था। HM brush heightmapCPU को बदलता है और उसे फिर से upload करता है। MC chunks अब heightmap से नहीं पढ़ते क्योंकि उनका SDF उससे भरा गया था और फिर अलग हो गया था। मैंने syncHeightmapToSdf() जोड़ा: heightmap बदलने के बाद, brush radius में किसी भी MC chunk के लिए SDF columns फिर से derive करो और नई values upload करो। दोनों brush types अब दोनों chunk types पर काम करते हैं।

हमने असल में क्या सीखा

brush spikes को एक performance सवाल का जवाब देना था: क्या sculpting frame budget के भीतर चल सकती है? चल सकती है। वह आसान हिस्सा था।

मुश्किल हिस्सा यह पता लगाना था कि 24 spikes तक height_at() को terrain की सच्चाई के रूप में इस्तेमाल करने से एक अदृश्य dependency बन गई थी जो उसी पल टूट गई जब हमने कुछ भी edit करने की कोशिश की। procedural function साफ़, global और stateless थी, ठीक उस पल तक जब तक वह terrain नहीं रही।

जो नियम हमने लिख लिए और भूलेंगे नहीं:

  1. Terrain height sampled data से आती है। chunks अपने buffers के मालिक होते हैं।
  2. Procedural generation initial data भरता है। यह runtime की सच्चाई नहीं है।
  3. brush chunk data को सीधे बदलता है। कोई displacement overlays नहीं।
  4. Normals उसी data से grid-aligned central differences के ज़रिए आती हैं।
  5. Border overlap (neighbor interior से 1 cell) cross-chunk normals संभालता है।
  6. जब एक proven copy मौजूद हो तो lookup table कभी दोबारा generate मत करो।

भाग 14 में हम debug geometry sculpt करना बंद करते हैं और उसे एक असली जगह जैसा दिखाने और महसूस कराने लगते हैं।

इस chapter में संदर्भित technology

Sampled heightmap architecture. Terrain को runtime पर किसी procedural function से evaluate करने के बजाय per chunk owned data के रूप में स्टोर किया जाता है। हर chunk के पास असली height values का एक Float32Array होता है। creation time पर procedural noise initial data भरता है, फिर function कभी call नहीं होता। इससे math-based terrain और edit overlays के बीच का dual-system mismatch ख़त्म होता है, brush operations सरल होते हैं (stored values को सीधे edit करो), और GPU shader बेहद सरल हो जाता है (buffer से पढ़ो, grid-aligned central differences के ज़रिए normals compute करो)। streaming वाले open world के लिए, neighbors से 1-cell border overlap के साथ per-chunk ownership standard तरीक़ा है। हमारी landscape generation guide देखें।

SDF brush operations. Terrain sculpt करने के लिए एक signed distance field को बदलना। Add (inflate) एक sphere के चारों ओर smooth-step falloff इस्तेमाल करता है। Subtract (carve) उसी shape को negated इस्तेमाल करता है। Smooth, Laplacian averaging इस्तेमाल करता है: 6 direct neighbors पढ़ो, उनका mean compute करो, mean की तरफ़ खींचो। शून्य की तरफ़ खींचने का naive तरीक़ा distance field को collapse करता है और तेज़ edges बनाता है। Laplacian smoothing field gradient को बनाए रखता है जबकि features को नरम करता है। SDF terrain representation देखें।

Transvoxel with mixed data sources. एक MC chunk और एक heightmap chunk के बीच की boundary पर transition cells को हर तरफ़ अलग data sample करना होता है। seam shader per-chunk flags और buffer bindings रखता है ताकि चारों combinations (HM-HM, HM-MC, MC-HM, MC-MC) संभाले जा सकें। जब एक तरफ़ MC-locked होती है, तो shader heightmap sample करने के बजाय SDF buffer को trilinearly interpolate करता है।

Stride-based LOD for marching cubes. SDF data chunk के मौजूदा LOD level की परवाह किए बिना full resolution पर स्टोर किया जाता है। MC shader SDF grid points और MC cells के ratio से एक sampling stride compute करता है। full resolution पर stride 1 है, half resolution पर stride 2 है। इससे SDF data, LOD changes से decouple हो जाता है, तो chunks SDF को rebuild किए बिना आज़ादी से LOD transition कर सकते हैं।


14 में से भाग 13. पिछला: भाग 12 - Rings, sky fog, और जो हम दोबारा करेंगे अगला: भाग 14 - The world comes alive सीरीज़ गाइड: /hi/blog/2026-02-25-open-world-browser-series-guide