ब्राउज़र में open world बनाना, भाग 18: एक scatter brush जो AI-placed महसूस होता है
लेखक Oleg Sidorkin, Cinevva के CTO और सह-संस्थापक
यहां नए हैं? सीरीज़ गाइड का इस्तेमाल करें। इसमें बताया गया है कि spike क्या होता है और सभी भागों के लिंक दिए गए हैं।
भाग 17 ने player को एक combat-grade animation set दिया और किसी भी CC0 model को world में खींचने का तरीका दिया। यह भाग वापस creator के पक्ष पर आता है। Spike 34 का palette हर click पर एक prop रखता है, जो किसी hero object को सेट करने के लिए ठीक है और एक forest के लिए बेकार है। Spike 37 वह brush है: terrain पर drag करें और trees वहां भर जाते हैं जहां trees होने चाहिए।
बिना किसी AI के "AI-placed"
Spike 37 को नए tab में खोलें ↗ · Source देखें
यह spike जिस सवाल का जवाब देता है वह यह है कि क्या एक पूरी तरह heuristic brush इतना intelligent महसूस होता है कि LLM को छोड़ा जा सके। "AI-placed" की कसौटी ठोस है: trees cliffs से दूर रहें, rocks ढलान में झुकें, beach pebbles waterline पर रुक जाएं, और यह सब पहले ही stroke में। हम वहां slope और altitude predicates, weighted draws, और per-family spacing से पहुंचे, और एक भी model call नहीं किया।
यह brush एक 257×257 CPU heightmap पर काम करता है जिसके features हाथ से tune किए गए हैं ताकि हर preset को कहीं न कहीं landing जगह मिले: mixed-slope picks के लिए northern mountains, scree के लिए एक eastern cliff strip, beach और meadow के लिए एक southern coastal plain, और south-west में एक lake bowl। Terrain एक (altitude, slope) biome classifier से vertex colors bake करता है, इसलिए एक भी tree paint करने से पहले आप देख सकते हैं कि कोई preset कहां fire करेगा। पांच presets flat data के रूप में आते हैं, हर एक { category, weight, slopeMin, slopeMax, altMin, altMax, minSpacing, alignToSlope } जैसे picks की एक list है। Cliff और Scree slopeMin: 0.3 सेट करते हैं ताकि rocks केवल वास्तविक slopes पर ही उतरें और alignToSlope: true ताकि हर boulder का up vector surface normal का अनुसरण करे।
हर stroke के लिए scatter engine brush disc के अंदर densityPerM2 × area candidate points sample करता है, हर candidate के लिए height और slope पढ़ता है, preset के picks को उन तक filter करता है जिनके predicates pass होते हैं, एक को weighted-draw करता है, फिर एक in-radius spatial hash के खिलाफ spacing check चलाता है। पूरी चीज़ deterministic है: एक seedable Mulberry32 RNG हर draw का मालिक है, इसलिए (seed, brush events) किसी भी session को बिल्कुल वैसा का वैसा reproduce करता है। boot terrain पर flat meadow पर एक Mixed Forest stroke ने 158 में से 139 candidates 5 ms में रखे, जबकि वही preset एक cliff पर केवल 226 में से 106 ही रख पाया और HUD ने बताया कि उनमें से 81 slope की वजह से reject हुए। वह rejection breakdown ही पूरा UX है: आप देख सकते हैं कि क्यों cliff ने कुछ ही trees लिए, बजाय अनुमान लगाने के।
presets को flat data के रूप में रखने का मकसद यह है कि LLM version, जब वह आएगा, एक JSON swap होगा न कि एक rewrite। paint({ preset }) को इससे फर्क नहीं पड़ता कि preset.picks किसी हाथ से tune की गई recipe से आए या किसी ऐसे worker से जिसने "deciduous forest with mossy boulders" को weights में फैला दिया। Engine कभी किसी prop id को hardcode भी नहीं करता, इसलिए एक अलग catalog डालने के लिए engine में कोई बदलाव नहीं चाहिए।
300 draw calls से 49 तक
पहले cut ने हर placement को एक multi-mesh group के clone(true) के रूप में render किया, जो कुछ सौ props पर तो ठीक है पर 2,500 के cap पर एक दीवार बन जाता है, जहां draw calls हज़ारों में पहुंच जाते हैं। उसके आने से पहले ही हमने InstancedMesh पर switch कर लिया, हर (propId, partIndex) के लिए एक bucket के साथ। हर bucket doubling से बढ़ता है: एक बड़ा InstancedMesh allocate करो, live matrices copy करो, scene parent swap करो, पुराने attribute को dispose करो। Erase एक swap-remove है, इसलिए एक instance को हटाना bucket size चाहे जो हो O(1) रहता है। Determinism, spacing, और rejection HUD सब बिना बदले चलते रहते हैं क्योंकि यह swap पूरी तरह placement record के नीचे रहता है।
MegaKit pack पर एक diagnostic ने एक असली architecture सवाल सुलझा दिया। एक multi-primitive glTF mesh (trunk और leaves) three.js तक या तो एक mesh के रूप में array material और geometry.groups के साथ पहुंच सकता है, या अलग-अलग sibling meshes के रूप में जिनमें से हर एक के पास एक material हो। इस pack के लिए loader दूसरा रास्ता लेता है: हर part empty groups वाला एक single-material mesh है। scatter के लिए यही बेहतर shape है, क्योंकि per-primitive अलग buckets trunk bucket को leaf bucket से स्वतंत्र रूप से बढ़ने देते हैं अगर उनकी counts अलग हो जाएं। draw-call count दोनों ही तरह से एक जैसी, split के साथ बेहतर memory shape। नापी गई जीत टिकी रही: एक forest stroke जो ~300 draw calls था वह 49 बन गया, और एक पूरे multi-stroke session ने 51 draw calls में 75 FPS पर 3,221 instances तक पहुंचा, एक ऐसा cap जिस तक clone path कभी पहुंच ही नहीं सकता था इससे पहले कि frame budget ढह जाता।
Distance LOD, और उसमें छिपे चार bugs
Instancing ने draw calls काट दिए पर हर instance अब भी अपनी पूरी triangle count draw करता था, यहां तक कि 90 m दूर वाले trees भी जो leaf detail के दो pixels योगदान कर रहे थे। तो हमने meshoptimizer के साथ हर prop part के लिए तीन LOD levels bake किए (full, 50%, 15%), bucket key को (propId, partIndex, lod) तक बढ़ाया, और एक move() जोड़ा जो किसी placement को sibling buckets के बीच बिना allocation शिफ्ट करता है। Distance bands 0 से 30 m, 30 से 90 m, और उससे आगे हैं, हर boundary के आसपास ±4 m hysteresis के साथ ताकि किसी band edge के पास मंडराता camera किसी placement को बार-बार आगे-पीछे न झटके और हर frame उसकी matrix दोबारा upload न करे। Re-evaluation 4 Hz पर capped है और तभी होती है जब camera वास्तव में हिला हो, इसलिए एक स्थिर camera per frame केवल एक squared-distance compare का खर्च लेता है।
वह LOD path ही वह जगह है जहां शिक्षाप्रद bugs रहते थे। पहला bug ऐसे दिखा कि जैसे-जैसे camera orbit करता placements गायब या duplicate होने लगते, और scene भरने के साथ और बदतर होते। वजह एक shared scratch matrix थी: move() किसी placement का transform module-scoped _tmpMat में पढ़ता था, पर source bucket का swap-remove अपनी internal shuffle के लिए उसी _tmpMat का इस्तेमाल करता था, और destination के लिखने से पहले ही carry की गई matrix को मिटा देता था। यह bug केवल उस case को छोड़ता था जहां move किया गया slot पहले से ही अपने bucket में आखिरी हो, लगभग 1/count की संभावना, जो ठीक वही "दुर्लभ flicker जो scene बढ़ने के साथ बदतर होती है" है जो playtest ने देखा। Fix move() के लिए अकेले reserved एक dedicated _carryMat था। 1,274 cumulative moves पर stress-test करने पर cluster pixel-identical रहा।
दूसरा bug ज़्यादा सूक्ष्म था: हर LOD transition smooth महसूस होता था सिवाय पहले वाले के। LOD1 में जाते trees साफ़-साफ़ shading बदलते दिखते थे भले ही उनका silhouette मुश्किल से बदलता था, जबकि ladder में बाद में बड़े triangle drops ठीक लगते थे। LockBorder वाला simplifier कभी vertices को न हिलाता है न नए बनाता है, इसलिए बचे हुए vertices अपने normals बिल्कुल वैसे ही रखते हैं, पर हम फिर भी हर simplification के बाद computeVertexNormals() call कर रहे थे। LOD0 artist के बनाए मूल normals को बिना छुए लौटाता है; LOD1 और उससे ऊपर को three.js का generic face-average recompute मिलता था। 0-से-1 boundary ladder में अकेली जगह थी जहां normal regime बदलता था, इसलिए pop वहीं रहता था। उस एक defensive line को हटाने से shading ठीक हुई और, एक bonus के रूप में, per-prop bake time लगभग आधा कट गया क्योंकि हमने हर part के चार LODs पर normals दोबारा compute करना बंद कर दिया।
simplifier ने क्या produce किया उसका audit करने से एक तीसरी जीत सामने आई। हर LOD एक नए index के साथ original.clone() था, और BufferGeometry.clone() हर attribute को deep-copy करता है, इसलिए पांच LODs position, normal, UV, और color buffers की पांच स्वतंत्र copies रखते थे जिनकी values सब में bit-identical थीं। हमने attribute references share करने के लिए refactor किया और per LOD केवल एक private index buffer अपने पास रखा, एक typical tree part को 20 अलग attribute identities से घटाकर 9 कर दिया और हर vertex buffer को GPU पर एक बार upload किया। aliased storage के साथ दो contracts आते हैं: किसी एक LOD के ज़रिए attribute data को mutate मत करो, और किसी एक LOD geometry को dispose() मत करो, क्योंकि दोनों ही उस buffer को share करने वाले हर sibling पर असर डालेंगे।
चौथे bug का painting से कोई लेना-देना नहीं था। बस cursor को terrain के ऊपर हिलाने से ही frame rate गिर जाता था, बिना कोई button दबाए। pointermove handler terrain mesh के खिलाफ raycast करता था, एक 131,072-triangle plane जिसमें कोई spatial structure नहीं थी, इसलिए three.js per event पूरे index buffer पर चलता था, और वह भी प्रति सेकंड 1,000 events तक। उस lookup के लिए हमें mesh की ज़रूरत थी ही नहीं, क्योंकि terrain एक parametric heightmap है। sampleHeight के खिलाफ एक adaptive ray-march (surface से ऊपर बड़े strides, उसके पास एक 0.4 m floor, फिर sign flip पर 12 bisections) per ray लगभग 8 से 30 samples का खर्च लेता है बजाय 131,072 triangle tests के, लगभग तीन orders of magnitude सस्ता, और hover फिर से frame cap पर टिका रहता है।
Cost बस जगह बदलता है; पक्का करो कि वह click से हट जाए
spike को three r184 (production target) पर WebGPURenderer पर switch करने के बाद, एक DevTools profile ने दिखाया कि सबसे पहला paint 265 ms के लिए block हो रहा है, उसका 79% meshoptimizer WASM के अंदर। Bake असली काम था, एक cold preset के लिए लगभग 180 simplify calls, पर वह click handler के अंदर चल रहा था क्योंकि preloadProps केवल scenes fetch और parse करता था, कभी LOD bake trigger नहीं करता था। Fix यह था कि preset selection को background में पूरा bake कराया जाए: preloadProps अब part-resolution path call करता है, in-flight promise को cache करता है ताकि एक तेज़ click उसमें शामिल हो जाए बजाय एक duplicate fork करने के, और per-geometry preprocessing को memoize करता है जिसे simplifier per part चार बार दोबारा कर रहा था। HUD में first paint 209 ms से घटकर 4 ms हो गया। WASM time गायब नहीं हुआ, वह बस user के critical path से निकल गया और तब चलता है जब वे terrain को देखते हुए तय कर रहे होते हैं कि कहां paint करना है।
यही इस spike का बार-बार आने वाला सबक है। इनमें से लगभग किसी fix ने यह नहीं बदला कि brush क्या करता है। उन्होंने यह बदला कि cost कब उतरता है: click से हटकर, hover से हटकर, उस boundary से हटकर जिसके पास camera मंडरा रहा है। एक scatter tool जो instant महसूस होता है वह कम काम नहीं कर रहा होता, वह काम वहां कर रहा होता है जहां user उसका इंतज़ार नहीं कर रहा होता।
इस अध्याय में संदर्भित technology
Heuristic suitability scatter. एक brush एक disc में candidate points sample करता है, हर point के लिए एक CPU heightmap से (height, slope) पढ़ता है, slope और altitude predicates से एक preset के picks को filter करता है, एक को weighted-draw करता है, और उसे reject कर देता है अगर वह एक spatial hash में tracked per-family minimum spacing का उल्लंघन करता है। Slope-aligned picks अपने up vector को surface normal की ओर rotate करते हैं। यह ऐसा placement produce करता है जो इरादतन पढ़ा जाता है (trees cliffs से दूर, rocks ढलानों में झुके, pebbles waterline पर रुके) बिना किसी learned weights के, और preset को flat data के रूप में रखता है ताकि एक LLM-generated pick list एक drop-in swap हो।
Async loads के तहत deterministic placement. एक seedable Mulberry32 RNG हर draw का मालिक है, इसलिए (seed, brush events) एक session को बिल्कुल वैसा reproduce करता है। RNG draws किसी भी await से पहले होते हैं, और spacing reservations glTF clone के resolve होने से पहले spatial index में डाल दी जाती हैं, इसलिए concurrent candidates एक-दूसरे का सम्मान करते हैं और async asset loading sequence को गड़बड़ नहीं कर सकती।
O(1) edits के साथ bucketed InstancedMesh. हर (propId, partIndex, lod) के लिए एक InstancedMesh, मांग पर live matrices को एक बड़े buffer में copy करके capacity दोगुनी की जाती है। Erase और FIFO-evict swap-remove हैं जिसमें एक back-reference array move किए गए instance का index patch करता है, इसलिए एक removal bucket size चाहे जो हो O(1) है। एक diagnostic ने पुष्टि की कि glTF parts single-material meshes के रूप में आते हैं, जिससे one-bucket-per-primitive active path बनता है और हर primitive को एक स्वतंत्र रूप से बढ़ने वाला bucket मिलता है।
Hysteresis और shared attribute buffers के साथ distance LOD. हर part के लिए तीन meshopt-simplified levels, ±4 m hysteresis वाले distance bands से चुने जाते हैं ताकि किसी boundary के पास का camera न झटके, एक capped rate पर re-evaluate किए जाते हैं और असली camera motion पर gated। चूंकि LockBorder simplification कभी vertices नहीं हिलाता, सभी LODs position/normal/UV/color buffers का एक set share करते हैं और केवल अपने private index buffer में फर्क रखते हैं, distinct GPU vertex buffers को लगभग आधा काट देते हैं। एक defensive computeVertexNormals को छोड़ने से artist normals सभी LODs में एक जैसे रहते हैं और ladder में अकेली shading discontinuity हट जाती है। देखें LOD और meshoptimizer।
High-frequency lookups के लिए analytic heightmap raycast. एक 131k-triangle plane mesh के खिलाफ pointermove-rate cursor lookup per event पूरे index buffer पर चलता है। इसे analytic height function के खिलाफ एक adaptive ray-march से बदलना (surface से दूर बड़े strides, उसके पास एक छोटा floor,
Interaction के critical path से काम हटाओ. महंगा one-time काम (meshopt LOD bakes, WGSL pipeline compiles) idle gaps के दौरान चलना चाहिए, click handler के अंदर नहीं। preset selection पर active preset का पूरा bake preload करना, in-flight promise को cache करना ताकि एक तेज़ click fork करने के बजाय उसमें शामिल हो जाए, और per-geometry preprocessing को memoize करना, इन सबने कुल काम कम किए बिना first-paint latency 209 ms से 4 ms तक गिरा दी।
29 में से भाग 18। पिछला: भाग 17 - ऐसी animations जिन्हें retargeting की ज़रूरत नहीं पड़ी, और एक live asset search अगला: भाग 19 - वह imposter जिसे एक forest में टिकना है सीरीज़ गाइड: /hi/blog/2026-02-25-open-world-browser-series-guide