Skip to content

ब्राउज़र में open world बनाना, भाग 23: पचास avatar और कमरे में एक आवाज़

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

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

भाग 22 ने world के ऊपर एक sky लगाया। यह भाग उसमें लोग डालता है। एक third-person open world चाहता है कि किसी भी पल में 50 से ज़्यादा character दिखें, और चाहता है कि आपके पास खड़े लोगों की आवाज़ सुनाई दे। Spike 45 rendering का पक्ष है: इतने सारे animated avatar को GPU पर लाना, वह भी main thread को पिघलाए बिना। Spike 46 audio का पक्ष है: peer-to-peer voice जो position के हिसाब से pan और attenuate होती है, ऐसे tune की गई कि किसी tech demo की जगह एक सामान्य video call जैसी लगे।

पचास नाचने वालों के लिए एक draw call

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

डिफ़ॉल्ट Three.js path हर character को उसका अपना SkinnedMesh, अपना AnimationMixer, अपना bone-matrix upload, और अपना draw call देता है। लोकल Mac पर 50 avatar पर यह करीब 13 ms का शुद्ध JavaScript overhead था per frame, इससे पहले कि GPU एक भी काम करे। पूरे multiplayer track के लिए de-risking सवाल यह था कि क्या एक batched-skinning architecture इसे काबू में ला सकता है और character count के साथ linearly scale कर सकता है।

जवाब इस बात में बँटा है कि animation कहाँ compute होती है और कहाँ draw होती है। तीन character class एक FBX template साझा करते हैं। लोकल player एक सामान्य Avatar है, अपने mixer वाला पूरा skeleton clone, मानक Three.js path, क्योंकि इसका हमेशा सिर्फ़ एक ही होता है। हर remote peer एक VirtualSkeleton है: यह भी अपने mixer के साथ एक पूरा clone जो वही clip चलाता है, लेकिन हर SkinnedMesh node को clone करने के तुरंत बाद हटा दिया जाता है ताकि सिर्फ़ bone बचें। यह कभी scene में नहीं आता। हर frame में, mixer के update होने और matrices के स्थिर हो जाने के बाद, यह सभी 100 bone के लिए (bone.matrixWorld × boneInverse) को एक साझा Float32Array के एक slot में pack करता है। फिर BatchSkinnedRenderer हर geometry piece के लिए एक InstancedMesh का मालिक होता है, ये सब एक ही StorageBufferAttribute से bone matrices पढ़ते हैं जिसका आकार maxInstances × numBones × mat4 है, यानी 60 × 100 × 64 = 384 KB। custom positionNode और normalNode वाला एक MeshStandardNodeMaterial हर vertex के लिए चार bone influence सीधे उस storage buffer से पढ़ता है। नतीजा यह कि पूरी भीड़ के लिए हर geometry piece पर एक storage upload और एक draw call होता है, चाहे उसमें कितने भी लोग हों। Skinning vertex shader में रहती है, और per-avatar JavaScript की लागत घटकर एक mixer चलाने और 100 matrix copy करने तक रह जाती है।

इसे मापने वाले HUD को भी फिर से बनाना पड़ा। पुराना version तब "over budget" दिखाता था जब पूरे-frame का GPU time 3 ms पार करता, लेकिन frame में हमेशा shadow map, ground, और लोकल player का पूरा skinned mesh शामिल होते हैं, जो मिलकर असली hardware पर 3 से 5 ms चलते हैं, चाहे कितने भी synthetic peer मौजूद हों। fix एक ऐसा budget है जो खुद को calibrate करता है: जब तक कोई batched avatar मौजूद न हो, यह live GPU time को एक तेज़ EMA के ज़रिए baseline के रूप में capture करता है, फिर उस baseline को freeze कर देता है और synthetic दिखने पर हर जुड़े avatar पर budget को 0.06 ms की दर से linearly बढ़ाता है। यह हर machine पर idle में PASS दिखाता है और भीड़ बढ़ने पर आनुपातिक रूप से कसता है।

bug एक ऐसा चेहरा था जो आपको दिखता नहीं था

Chrome में पहले run में ground पर silver shadow और कोई avatar बिल्कुल नहीं दिखा, साथ में एक WGSL parse error: एक ऐसी line पर cannot index type 'f32' जो object.nodeUniform2[i] को subscript करने की कोशिश कर रही थी जहाँ uniform एक scalar के रूप में declare किया गया था। इस कहानी का ईमानदार हिस्सा यह है कि पहला fix ग़लत था और फिर भी काम कर गया। अंदाज़ा यह था कि InstancedMesh का instance-matrix path ख़राब code बना रहा था, और एक StorageInstancedBufferAttribute डालने से Chrome में error ग़ायब हो गया। पर वह इसलिए ग़ायब हुआ क्योंकि नए path ने अलग shader code emit किया, इसलिए नहीं कि उसने वजह को हल किया, और यही सबसे ख़तरनाक तरह का fix है।

असली अपराधी थे morph target। 3MIKE.fbx blend-shape facial expression के साथ आता है, cloned geometry morphAttributes को विरासत में लेती है, और Three.js का MorphNode.setup() morphTargetInfluences को एक scalar float के रूप में declare करता है और फिर एक synthesized loop के अंदर उसे .element(i) करने की कोशिश करता है, जो ठीक वही scalar-subscript है जिसे compiler ने नकार दिया। fix एक line है, उस geometry पर geometry.morphAttributes = {} clear करना जो morph का इस्तेमाल नहीं करती, ताकि Three.js MorphNode को inject ही न करे। वह अनजाने वाला Chrome fix कुछ देर के लिए टिका रहा और फिर पलटकर काट गया: Safari पर, storage-instanced path ने 256 बार Vertex buffer is not big enough दिया, क्योंकि Safari का WebGPU backend इसे साफ़-सुथरे ढंग से translate नहीं करता। उसे revert करना सही फ़ैसला था, और सादा bone-matrix storage buffer, जो किसी generated instance path के बजाय core WebGPU है, हर जगह ठीक काम करता है। सीखने लायक सबक़ यही है: जब कोई fix एक browser पर काम करे और आप उसकी मशीनरी समझा न सकें, तो आपने किसी लक्षण पर पट्टी लगाई है, इसलिए असली generated WGSL पढ़िए। spike में बाद में जोड़ा गया एक getCompilationInfo() shim Three.js के सामान्य "module is not valid" को असली Tint error में बदल देता है और उसने अपनी क़ीमत कई गुना वसूल कर ली।

इसके पास ही एक मिलती-जुलती framework-evasion तरकीब बैठी है। Three.js मानक skinIndex और skinWeight attribute नाम पहचान लेता है और अपना SkinningNode inject करने की कोशिश करता है, उस InstancedMesh पर भी जिसका custom positionNode पहले से ही skinning करता है। उन attribute के नाम बदलकर boneIndex और boneWeight करने से वे framework से छिप जाते हैं, और custom TSL उन्हें नए नामों से पढ़ता है।

एक relay जो शब्दों के बीच आपको भूल जाता है

पहले version ने peer को BroadcastChannel पर sync किया, यानी असली wire format और cadence के साथ एक same-browser नमूना, और protocol comment में वादा था कि असली transport में बदलना एक line का काम होगा। उस वादे को भुनाने का मतलब था एक AvatarRoomDO, एक 74-line का Cloudflare Durable Object जो 36-byte वाली binary frame को decode तक नहीं करता। यह हर message को जैसा है वैसा ही कमरे के बाकी हर peer को आगे भेज देता है, क्योंकि भेजने वाले की id frame में embedded है और हर receiver अपनी ही echo को client-side filter कर लेता है। relay को identity की कोई जानकारी नहीं होती। Hibernating WebSocket एक खाली कमरे को मुफ़्त बना देते हैं: messages के बीच DO memory से बाहर निकल जाता है और runtime अगले packet पर tagged socket बहाल कर देता है। हर peer के 10 event per second पर यह 36,000 DO request per peer-hour है, करीब आधा सेंट, Cloudflare पर egress मुफ़्त और equivalent AWS WebSocket आकार से करीब 6 से 10 गुना सस्ता।

इस बदलाव ने एक state-machine bug सामने ला दिया जो रखने लायक है। एक remote player रुकने के बाद भी चलता रहा। animation request this._state यानी अभी चल रहे clip के विरुद्ध guard कर रही थी, न कि आख़िरी queued नाम के, इसलिए जब दो network message एक ही tick में आए, पहले walk फिर idle, तो idle एक ऐसे state से तुलना कर रहा था जो अभी आगे बढ़ा ही नहीं था और चुपचाप drop हो गया। peer हमेशा के लिए चलने में अटक गया, क्योंकि आगे आने वाले idle packet upstream में बिना बदलाव के deduplicate हो रहे थे। fix यह है कि हमेशा pending नाम को overwrite किया जाए और transition helper को असली same-state request को short-circuit करने दिया जाए, जो वह पहले से करता था। यह bug का एक आम वर्ग है: ग़लत reference value के विरुद्ध एक dedup check चुपचाप उसी input को निगल जाता है जो मायने रखता है।

Safari को दो और guard की ज़रूरत थी। यह WebSocket को Chrome से तेज़ खोलता है, इसलिए पहला inbound peer message batch renderer के बन जाने से पहले आ सकता था और null को dereference कर देता था; renderer के मौजूद न होने पर messages drop करना सुरक्षित है क्योंकि peer हर 100 ms में दोबारा broadcast करते हैं। और 'gpu' in navigator ने true लौटाया जबकि requestAdapter() ने null लौटाया, इसलिए Three.js चुपचाप WebGL2 पर वापस आ गया, जहाँ storage-buffer skinning chain का कोई valid translation नहीं है और उसने errors उगल दिए। एक असली adapter की जाँच करना और यह सुनिश्चित करना कि backend सचमुच WebGPU है, एक degraded render को एक साफ़ loading-screen message में बदल देता है। यहाँ तक कि एक WGSL dialect gap भी था: Three.js आधुनिक दो-argument वाला @interpolate(flat, either) emit करता है जिसे WebKit के compiler ने अभी भेजा नहीं है, इसे createShaderModule में जाते वक़्त shader source को rewrite करके दूसरा argument हटाकर patch किया गया, जो मुफ़्त है क्योंकि flat interpolation हर vertex पर एक ही value रखती है चाहे जो भी हो।

वह आवाज़ जो कमरे के साथ pan होती है

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

Spike 46 proximity voice है: HRTF spatial audio के साथ peer-to-peer WebRTC, साफ़ तौर पर इस दायरे में रखी गई कि एक शांत-से-मध्यम-शोर वाले कमरे में Google Meet और Microsoft Teams की क्वालिटी से मेल खाए। एक VoiceRoomDO signaling को एक JSON relay की तरह संभालता है, हर नए peer को एक roster भेजता है, join और leave की घोषणा करता है, SDP और ICE को socket tag के ज़रिए एक ख़ास peer तक route करता है, और position update broadcast करता है जो spatial panner को चलाते हैं। यह हर message पर भेजने वाले की id छाप देता है ताकि peer एक-दूसरे की नकल न कर सकें, और audio खुद कभी DO को नहीं छूता। हर remote peer के लिए एक RTCPeerConnection, जहाँ lexicographically छोटी peer id हमेशा offer बनाती है ताकि दोनों पक्ष इस बात पर सहमत हो जाएँ कि कौन शुरू करता है, बिना किसी पूरे perfect-negotiation implementation के।

receive side पर हर peer का audio एक PannerNode से होकर गुज़रता है जो HRTF पर inverse distance rolloff के साथ सेट है, और AudioListener हर frame लोकल player की position और facing से update होता है, forwardX = sin(facing), forwardZ = cos(facing) का इस्तेमाल करते हुए, जो scene के atan2(wx, wz) facing convention से मेल खाता है। Chrome की एक ख़ासियत ने एक घंटा खर्च करा दिया: सिर्फ़ Web Audio द्वारा consume होने वाली एक MediaStream कभी-कभी packet नहीं खींचती, इसलिए हर stream एक छिपे हुए muted <audio> element से भी जुड़ती है ताकि decoder को schedule करने पर मजबूर किया जा सके। क्वालिटी की ओर, browser डिफ़ॉल्ट रूप से करीब 32 kbps mono Opus चलाते हैं, इसलिए spike हर offer और answer पर fmtp line को बदलकर उसे 128 kbps तक बढ़ाता है, in-band FEC enabled और DTX disabled के साथ, फिर एक ऊँची max bitrate के साथ setParameters call करता है ताकि गारंटी रहे कि encoder सचमुच वही इस्तेमाल करे जो SDP बताता है। bitrate बढ़ाने के बाद FEC दूसरी सबसे बड़ी सुनाई देने वाली जीत है, यह बिना renegotiation के packet loss से उबर जाता है।

साफ़ audio तक पहुँचने का रास्ता हटाते हुए

जो audio chain भेजी गई वह उससे कहीं छोटी है जिससे मैंने शुरुआत की थी, और उसे सिकोड़ना ही असली सबक़ था। पहले version में एक high-pass filter, keyboard noise पकड़ने के लिए tune किया गया एक click limiter, एक compressor, एक noise gate, और एक wet-dry crossfade था, जिसके आगे एक floating panel था जिसमें बारह से ज़्यादा slider थे। जब user ने सुनाई देने वाले keyboard click की रिपोर्ट की, तो सहज प्रवृत्ति यह थी कि click limiter को और ज़ोर से tune किया जाए और dry mix घटाई जाए, यानी पट्टियों का ढेर। structural जवाब यह था कि जैसे ही chain में एक ML denoiser आ जाता है, click limiter और gate और ज़्यादातर high-pass सब बेकार हो जाते हैं, क्योंकि RNNoise को ठीक keyboard, mouse और typing noise पर ही train किया गया है, और amplitude clipping उसी काम का एक सख़्ती से घटिया version है। production client ML denoise, echo cancellation, automatic gain, और level के लिए एक soft compressor भेजते हैं, और कुछ नहीं। तो चार stage बाहर हो गए, slider panel बाहर हो गया, और "अपनी noise reduction चुनें" वाले toggle बाहर हो गए, और पीछे एक तय pipeline रह गई।

बचा हुआ हर stage अपनी जगह कमाता है। Browser echo cancellation चालू रहता है क्योंकि RNNoise echo नहीं संभालता, और इसके बिना speaker-से-mic feedback की कोई सीमा नहीं रहती। Browser noise suppression बंद रहता है क्योंकि उसे RNNoise के ऊपर रखने से s, sh, और f जैसी fricative पर artifact बनते हैं, इसलिए आप एक ही denoiser चुनते हैं। Browser automatic gain चालू रहता है, क्योंकि उसे बंद करने से signal इतना धीमा हो जाता था कि compressor के काम लायक न रहे और Web Audio के DynamicsCompressorNode में भरपाई के लिए कोई makeup-gain parameter नहीं है; browser का व्यापक levelling और spike का तेज़ compressor अलग-अलग timescale पर काम करते हैं और साथ रहते हैं। RNNoise 92 प्रतिशत wet पर चलता है, 8 प्रतिशत dry के साथ मिलाकर, क्योंकि यह s, sh, और f जैसे unvoiced consonant को ज़्यादा दबा सकता है जिनकी voice probability गिर जाती है, और छोटा dry path उन्हें थोड़े से keystroke leak की क़ीमत पर बचाए रखता है।

दो feature इसे पूरा करते हैं। Push-to-talk track.enabled को नहीं पलटता, क्योंकि वह pipeline buffer में बचा हुआ सब कुछ फेंक देता है और key छोड़ने पर आख़िरी syllable काट देता है। इसके बजाय tail के पास एक GainNode setTargetAtTime के साथ ramp करता है, तेज़ attack ताकि पहली syllable बची रहे और धीमी release ताकि आख़िरी consonant निकल जाए, और track को हमेशा के लिए enabled छोड़ दिया जाता है। और एक पाँच-सेकंड का broadcast delay, जो एक radio-style feature के रूप में माँगा गया था, एक bypass leg और एक DelayNode leg को साथ crossfade करके चलता है, एक dump button के साथ जो delayed output को silence पर ले जाता है और audio लौटने से पहले HUD पर उल्टी गिनती करता है। denoiser को bundle करना अपने आप में एक छोटी गाथा थी: published RNNoise worklet ऐसे bare-specifier import इस्तेमाल करता है जिन्हें कोई CDN resolve नहीं करता, इसलिए fix एक local esbuild bundle था जो एक self-contained 1.9 MB file बनाता है जिसमें WASM base64-inlined है, repo में commit किया गया और module के सापेक्ष एक URL से referenced किया गया ताकि वह dev server, VitePress build, और custom domain सब के तहत resolve हो जाए। अगर worklet कभी load होने में नाकाम रहे, तो chain फिर भी एक सादे high-pass और compressor के ज़रिए audio बनाती रहती है, और HUD उस नाकामी को लाल रंग में दिखाता है।

इस अध्याय में जिस तकनीक का ज़िक्र हुआ

भीड़ के लिए batched GPU skinning. Remote avatar एक headless VirtualSkeleton चलाते हैं (एक पूरा clone जिसमें skinned mesh हटा दिए गए हैं, bone रखे गए हैं, अपना mixer है) जो हर bone के लिए bone.matrixWorld × boneInverse को एक साझा StorageBufferAttribute में pack करता है। हर geometry piece के लिए एक InstancedMesh उन matrices को एक custom TSL positionNode/normalNode में पढ़ता है, इसलिए पूरी भीड़ की लागत हर piece पर एक storage upload और एक draw call है, जहाँ per-avatar CPU काम एक mixer update और एक matrix copy तक सीमित है। देखें GPU-driven LOD

लक्षण नहीं, generated WGSL पढ़ना. एक cannot index type 'f32' compile error का सुराग Three.js के MorphNode तक पहुँचा जो morphTargetInfluences को एक scalar के रूप में declare करके उसे subscript कर रहा था, जिसे उस geometry पर morphAttributes clear करके fix किया गया जो morph का इस्तेमाल नहीं करती। एक पहला fix जिसने सिर्फ़ यह बदला कि कौन सा shader path generate हुआ, वजह को छिपा गया और बाद में Safari को तोड़ गया। skinIndex/skinWeight का नाम बदलकर boneIndex/boneWeight करने से attribute Three.js के automatic SkinningNode injection से छिप जाते हैं ताकि एक custom skinning material गणित का मालिक बने।

Hibernating Durable Object relay. एक pure-binary AvatarRoomDO 36-byte frame को बिना decode किए बाकी हर peer को आगे भेजता है, जहाँ भेजने वाले की पहचान frame में embedded है और self-echo client-side filter होती है। Hibernating WebSocket एक खाली कमरे को मुफ़्त बना देते हैं, और यह आकार 10 Hz पर करीब आधा सेंट per peer-hour खर्च करता है, equivalent managed-WebSocket pricing से कहीं नीचे। एक dedup guard जो last-queued के बजाय चल रहे animation state से तुलना करता था, चुपचाप stop message drop कर देता था और remote player को एक walk loop में अटका देता था।

HRTF के साथ WebRTC proximity voice. हर peer के लिए एक RTCPeerConnection जहाँ offer/answer की भूमिकाएँ peer-id क्रम से तय होती हैं, audio एक HRTF PannerNode से route होता है जहाँ AudioListener हर frame player facing से update होता है, और Opus को resilience के लिए in-band FEC के साथ 128 kbps तक बदला जाता है। एक muted छिपा हुआ <audio> element Chrome को एक Web-Audio-only stream से packet खींचने पर मजबूर करता है।

Subtractive audio engineering. Meet/Teams की क्वालिटी से मेल खाने का मतलब था stage जोड़ना नहीं, हटाना: ML denoise और echo cancellation और automatic gain और एक soft compressor, बिना किसी gate और बिना किसी click limiter के, क्योंकि keyboard noise पर train किया गया एक ML denoiser amplitude clipping को बेकार बना देता है। Push-to-talk track पलटने के बजाय एक asymmetric envelope के साथ एक tail GainNode को ramp करता है ताकि syllable न कटें, और denoiser worklet bare-specifier import resolution से बचने के लिए एक अकेले self-contained esbuild bundle के रूप में भेजा जाता है।


29 में से भाग 23। पिछला: भाग 22 - वो बादल जिन्हें आप light कर सकते हैं, और वह culling जिसे खिलाना पड़ता है अगला: भाग 24 - एक world को save करना, और वह हवा जो आप देख सकते हैं सीरीज़ गाइड: /hi/blog/2026-02-25-open-world-browser-series-guide