Saltline là một game bắn súng góc nhìn thứ nhất chạy bằng Three.js ngay trong trình duyệt. Toàn bộ game gói trong một file JavaScript 1,39 MB và 30 KB CSS. Không có file GLB, FBX, PNG hay MP3 nào đi kèm: container, súng, robot, mặt đường ướt, bầu trời và cả tiếng súng đều được sinh ra bằng code khi game khởi động.
Bài này mổ xẻ các kỹ thuật procedural đó: cách game dựng hình học, bake texture PBR trên GPU, vẽ mưa và vũng nước, gói hiệu ứng chiến đấu vào vài draw call và tổng hợp âm thanh từ DSP. Bạn có thể chơi Saltline tại meowsolo.com/gameaaa trước khi đọc.

Saltline là gì và bài phân tích dựa trên đâu
Saltline đặt người chơi vào một cảng container lúc chiều mưa. Nhiệm vụ là cắt ba relay của hệ thống an ninh đã mất kiểm soát rồi chạy ra thuyền. Kẻ địch có hai loại: hound là robot bốn chân, gull là drone bốn rotor.
Game gốc được chia sẻ công khai dưới dạng Claude Artifact. Meow Solo thu hồi bundle, giữ nguyên từng byte của game.js và chạy độc lập tại meowsolo.com/gameaaa. Game dùng Three.js r180, WebGL2 và JavaScript thuần. Phân tích dưới đây đọc tĩnh bản bundle đã định dạng lại; tên hàm trong bundle đã bị minify nên các hệ thống được gọi theo chức năng.
Đo lúc chạy ở preset high, độ phân giải 1600×900: màn hình tiêu đề tốn khoảng 307 draw call, 1,87 triệu tam giác, 137 texture và 107 shader program. Level có 298 prop, trong đó 47 container, và 96 nhãn được gom vào một atlas.

Ba cơ chế sinh asset lặp lại ở mọi hệ thống
Mọi thứ trong Saltline đều đi qua một trong ba cơ chế:
- Hình học: ghép từ primitive tự viết, bake transform và màu đỉnh, rồi merge theo material.
- Vật liệu: texture bake một lần trên GPU bằng GLSL, gắn vào
MeshStandardMaterialđã chèn thêm shader. - Hiệu ứng: thứ chạy nền (mưa, chim, đèn nháy) nằm hoàn toàn trong vertex shader; thứ thuộc gameplay (hạt, vỏ đạn, decal) chạy trên pool CPU có ngân sách cố định.
Hình học: ghép từ primitive tự viết
Mẫu part list và merge theo material
Mọi builder dùng chung mẫu "part list": tạo primitive, bake transform vào geometry, ghi vertex color, đẩy vào bucket theo material, cuối cùng merge mỗi bucket thành một mesh. Game có hai class builder cho props, một bộ lệnh dựng level, một batcher cho phông cảnh và một builder rig; tất cả cùng một ý.
Level tĩnh sau đó được gộp thêm một lần theo material.uuid, cờ cast/receive shadow và layer. Vì vậy số draw call xấp xỉ số material chứ không tăng theo số vật thể. Mesh nằm dưới node có cờ noMerge (quạt, relay, spreader của cẩu) được giữ riêng để còn chuyển động. Props không dùng InstancedMesh.
Bộ primitive tự viết
| Kỹ thuật | Cách làm |
|---|---|
| UV theo mét | Box projection theo trục normal trội; mọi primitive xuất UV đơn vị mét nên texel density đều sau khi merge |
| Chamfer box | 24 đỉnh, 44 tam giác; dải vát dùng chung đỉnh hai mặt kề nên normal tự nội suy, cạnh bắt highlight mà không tốn thêm đỉnh |
| Rounded box | BoxGeometry 3×3×3, đỉnh ở cạnh và góc được chiếu lên bán kính bo quanh hộp lõi |
| Loft | Profile 2D nhân danh sách station qua một hàm ánh xạ, tự sửa winding; dùng cho vách container và barrier |
| Tôn container | Profile hình thang chỉ phát đỉnh tại 4 điểm gãy mỗi chu kỳ |
| Vết móp | Danh sách vết có seed, độ lún cộng dồn với falloff (1−d²)² |
| Lathe | Normal giải tích từ tiếp tuyến profile; lặp một điểm hai lần để tạo cạnh cứng |
| Extrude | Normal mượt khi góc giữa hai cạnh dưới ngưỡng, ngược lại cứng; lỗ thật bằng Shape.holes, không có CSG |
| Ống và cáp | TubeGeometry trên CatmullRomCurve3, số đoạn theo chiều dài |
| Hàn normal | Cộng normal mặt theo vị trí lượng tử 0,5 mm, làm mượt xuyên seam UV |
Vách container dưới đây là kết quả của profile tôn hình thang, vết móp có seed, vertex color cho rãnh tối và decal logo vẽ bằng Canvas2D.

Hình mềm: bạt, bao cát, carton móp
Hình mềm đều là rounded box chia lưới, đi qua một hàm biến dạng rồi hàn normal.
- Bạt phủ: heightfield lấy giá trị lớn nhất của các "lều" dựng quanh từng thùng hàng, cộng noise và nếp gấp sin. Dây chằng lấy mẫu từ chính bề mặt bạt nên luôn nằm sát mặt.
- Bao cát: thót hai đầu theo luỹ thừa, phồng mặt trên, võng theo lớp, thêm noise.
- Carton móp: đỉnh sụp theo bình phương cao độ; băng keo đi qua cùng hàm biến dạng nên bám theo mặt móp.

Vertex color làm texture thứ hai
- AO trong rãnh tôn: tối 17–18% theo độ sâu rãnh.
- Bẩn và AO khi hoàn tất prop: hai octave value noise, AO bán cầu theo
normal.y, dải 20 cm sát đất tối và ngả nâu.
_ = (0.82 + 0.18*m) * (0.92 + 0.08*A) * (0.76 + 0.24*clamp(ny*0.6 + 0.62, 0, 1));
M = clamp(1 - y/0.2, 0, 1); // dải sát đất
_ *= 1 - 0.42*M*M*(0.55 + 0.45*A);- Lỗ giả: plane hoặc đĩa tối đặt lệch 1–2 mm thay cho phép boolean.
- Giá trị lớn hơn 1: tạo phần thép sáng trong material tối, gom nhiều "vật liệu" vào một draw call; mòn cạnh chỉ áp lên đỉnh thuộc mặt vát.
- Aerial perspective: độ mờ theo khoảng cách được bake sẵn vào màu đỉnh của skyline.
Seed, cache và collider
- Seed: mỗi builder có RNG mulberry32 riêng; nhãn, màu, vết móp, ngăn kéo mở đều lấy từ seed.
- Cache geometry container: khoá gồm tên, seed lượng tử hoá, góc cửa và độ mòn. Thân container dùng material giữ chỗ; lúc tạo instance mới ánh xạ sang
paint_<màu>, nên 8 màu sơn dùng chung một geometry. - Collider: danh sách AABB viết tay đi kèm prop. Prop đặt lệch trục bị cắt tối đa 10 lát theo trục dài. Cùng các AABB đó nuôi raycast và nav grid.
Level và phông cảnh
- Level là code thủ tục với toạ độ viết thẳng, không có file dữ liệu. Chồng container nhận jitter vị trí, yaw và seed riêng cho từng thùng.
- LOD bằng cách chạy lại builder: cẩu ở xa chính là builder cẩu gần, ép về một material và bỏ mọi mảnh nhỏ hơn 2,6 m.
- Thân tàu: một hàm tham số theo station, cao độ và mạn; normal tính bằng sai phân trung tâm.
- Đèn xa: mọi đèn là octahedron 8 tam giác trong một batch; attribute
blink(chu kỳ, pha, duty) được xử lý ở vertex shader. Cửa sổ sáng trên tàu là hộp mỏng chọn bằng hash, không dùng texture. - Chim: 12 con trong một mesh, quỹ đạo và nhịp vỗ cánh tính ở vertex shader.
Vật liệu: texture PBR bake một lần trên GPU
29 họ texture viết bằng GLSL
Mỗi họ texture trong 29 họ là một hàm GLSL surf(uv) viết trên một thư viện chung. Thư viện gồm hash PCG có chu kỳ (nên texture tile liền mạch), gradient noise, fbm, voronoi F1/F2, vết xước và mô hình tróc sơn với mảng tróc, quầng oxit, vệt gỉ chảy xuống.
Shader chạy một lần vào render target có 3 attachment:
o0 = vec4(sqrt(clamp(c, 0.0, 1.0)), sat(a)); // albedo + mặt nạ tint
o1 = vec4(sat(ao), clamp(r, 0.03, 1.0), sat(m), 1.0); // AO, roughness, metalness
float hh = sat(h) * 255.0; float fl = floor(hh);
o2 = vec4(fl / 255.0, hh - fl, 0.0, 1.0); // height 16 bit trên 2 kênh- Pass thứ hai đổi height thành normal map bằng sai phân 8 điểm lân cận; độ mạnh của bump tính theo kích thước tile thật.
- Kết quả được copy sang texture có mipmap và anisotropy. Kích thước theo quality, từ 256 đến 2048. Target trung gian bị huỷ sau 4 giây không dùng.
- Texture sinh theo nhu cầu: họ nào chưa được vật liệu nào gọi thì không bake.
Ba ô dưới đây là texture thép gỉ mà game bake ra, trích thẳng từ GPU lúc chạy: albedo, normal map dựng từ height, và ORM với AO ở kênh đỏ, roughness ở kênh xanh lá, metalness ở kênh xanh dương.

Tám họ texture khác cũng được trích ra cùng cách: thép gỉ, bê tông, gỗ pallet, carton, thép chống trượt, lưới thép, vạch cảnh báo và thép mạ kẽm.

Alpha của albedo là mặt nạ tint
Shader sơn xuất riêng lớp không nhuộm (gỉ, lớp lót, bẩn) và lượng sơn còn lại. Lúc render, màu sơn được cộng vào theo alpha:
diffuseColor.rgb = sampledDiffuseColor.rgb + diffuse * sampledDiffuseColor.a;Một bộ texture sơn cho ra 8 màu, còn gỉ và bẩn giữ màu riêng. Ô đầu tiên là kênh màu của texture sơn container, gần như đen vì chỉ chứa phần không nhuộm. Ô thứ hai là kênh alpha, tức lượng sơn còn lại. Bốn ô sau ghép lại theo công thức trên với bốn màu sơn của game: đỏ, xanh dương, xanh lá và cam.

Vá MeshStandardMaterial bằng onBeforeCompile
48 preset vật liệu đều là MeshStandardMaterial. Shader được chèn qua onBeforeCompile, nên game giữ nguyên bóng, IBL và BRDF của Three.js. Mỗi preset gồm họ texture, kiểu tint, hệ số rough/metal, bộ tham số ướt và bộ tham số biến thiên vĩ mô. Phần chèn thêm gồm:
- Một lần đọc ORM: AO, roughness, metalness lấy từ cùng một texture.
- Biến thiên vĩ mô: value noise 3D theo world-space đổi albedo và roughness để phá lặp tile.
- Ướt mưa: mạnh nhất ở mặt ngửa, thành vệt trên mặt đứng, dải ẩm sát đất, hạt nước đọng khi nhìn gần. Albedo tối và bão hoà hơn, roughness giảm, normal phẳng lại.
- Triplanar: biến thể cho mesh skinned và instanced.
- Lưới thép ở xa: ngưỡng alpha hạ theo mip để sợi giữ khoảng 1 pixel, rồi chuyển sang dither khi mắt lưới nhỏ hơn pixel.
- Chống lấp lánh: tăng roughness theo độ ngắn của vector normal map đã lọc.
Súng có thêm một detail shader riêng: noise object-space tạo xước dị hướng, vòng tiện, stipple polymer và giọt nước mưa.
Decal và nhãn vẽ bằng Canvas2D
- Decal vẽ bằng Canvas2D với RNG có seed lấy từ hash của tên.
- Chữ stencil: vẽ chữ rồi khoét một khe dọc bằng
destination-out. - Mài mòn: đa giác, vết xước và đốm được khoét bằng
destination-out. - Tô sẵn màu nền với alpha rất thấp để mipmap không tạo viền tối.
- Nhãn được vẽ lại khi webfont tải xong.
- Material decal:
transparent,depthWrite: false,polygonOffset −2.
Toàn bộ decal trong cảng, trích từ canvas lúc game chạy: biển cảnh báo, logo hãng tàu, biển CSC, mũi tên, số stencil, vệt gỉ, vệt dầu và vết cháy. Khe dọc trên chữ stencil và các mảng tróc chính là nét khoét destination-out; khung mờ quanh mỗi decal là lớp nền alpha thấp chống viền tối.

Nhãn số hiệu container, tải trọng, mã lô và tên relay được gom vào một atlas rộng 2048 px bằng shelf packing: xếp nhãn thành từng hàng theo chiều cao, hết chỗ thì xuống hàng mới. Mỗi nhãn được vẽ giãn ra vùng đệm để mipmap không lem sang nhãn bên cạnh. 96 nhãn của level chỉ chiếm khoảng nửa trên của atlas.

Ánh sáng, bóng và hậu kỳ
Bầu trời một shader, kiêm environment map
Một fragment shader trên khối hộp vẽ toàn bộ bầu trời: tầng mây 2D bằng fbm có domain warp, khe hở về phía mặt trời, mây tự đổ bóng bằng chênh lệch mật độ theo hướng nắng, và chớp trong mây. Chính shader đó được chụp qua PMREM làm environment map, có bỏ đĩa mặt trời và kẹp độ sáng để tránh đốm.

Bóng nắng PCSS trên một shadow map
- Một
DirectionalLightvới một shadow map khít theo AABB của level, không cascade. Kích thước 1024–4096 tuỳ quality. - Hàm
getShadowtrong shader chunk của Three.js bị thay bằng PCSS: Vogel disk xoay bằng interleaved gradient noise, 8 tap tìm blocker, 10–16 tap lọc, penumbra rộng theo khoảng cách blocker, có receiver-plane depth bias. - Bóng cập nhật cách frame, trừ mức ultra.
- 6
PointLightdùng chung cho chớp nòng, vụ nổ và đèn bám theo vật.
Súng không xuyên tường
Camera thứ hai với FOV 54 (camera thế giới là 74) chỉ thấy layer của súng. Súng được vẽ vào cùng HDR target với gl.depthRange(0, 0.05), nên luôn nằm trước mọi thứ mà vẫn nhận chung hậu kỳ với cảnh.
Chuỗi hậu kỳ 9 pass
Cảnh render vào target HalfFloat có MSAA 0/2/4 và depth texture. Thứ tự pass:
- Depth tuyến tính ở nửa độ phân giải.
- AO 12 tap, normal dựng lại từ depth, lọc bilateral.
- Volumetric raymarch 10–40 bước, đọc thẳng shadow map của mặt trời; phase Henyey-Greenstein hai thuỳ; không khí đặc hơn trong hộp nhà kho; phần xa tính giải tích.
- Upsample bilateral rồi ghép AO và sương vào cảnh.
- Motion blur của camera bằng reprojection theo depth.
- Bloom 6 tầng với Karis average ở tầng đầu, thêm vệt anamorphic.
- Auto exposure, có kiểm tra mặt trời bị che để điều khiển flare.
- Pass màu: chromatic aberration, lens dirt, flare, tonemap ACES kéo về AgX, split-tone, vignette, hiệu ứng bị thương.
- FXAA, sharpen, film grain, dither.
Pass volumetric đọc shadow map của mặt trời, nên trong nhà kho, tia nắng qua cửa bị cắt đúng theo khung cửa và không khí bên trong dày hơn ngoài trời.

Preset chất lượng và độ phân giải động
| Preset | Scale | MSAA | Shadow | Texture | Bước volumetric | Planar | Motion blur | Mưa |
|---|---|---|---|---|---|---|---|---|
| low | 0.7 | 0 | 2048 | 512 | 12 | tắt | tắt | 0.5 |
| medium | 0.85 | 2 | 2048 | 1024 | 16 | 0.5 | tắt | 0.8 |
| high | 1 | 4 | 4096 | 1024 | 24 | 0.5 | bật | 1 |
| ultra | 1 | 4 | 4096 | 2048 | 40 | 1 | bật | 1 |
Preset low còn tắt AO. Game tự chọn preset: thiết bị di động dùng low; GPU phần mềm, Mali, Adreno, PowerVR hoặc Intel không phải Arc dùng medium; còn lại dùng high. Khi chạy, scale độ phân giải dao động 0,6–1,0 theo frame time, và game tự hạ preset nếu chạm sàn hai lần.

Mặt đất ướt, biển và mưa
Dữ liệu mặt đất sinh trên CPU
- Mask 80×80 m: kênh R là cao độ nền với 26 hố vũng đặt tay và vệt lún bánh xe, G là độ ẩm, B là dầu, A là độ mòn theo làn xe.
- Detail tileable: grain, loang, vết nứt bằng ridged fbm, kèm hai slope map trong một texture.
- Sơn kẻ vạch: vẽ bằng Canvas2D theo đơn vị mét.
- Cùng mảng
Uint8Arraycủa mask trả lờiisPuddlevàwetnessAtcho gameplay, nên chỗ nhìn thấy ướt cũng là chỗ gameplay coi là ướt.
Shader mặt đất ướt suy từ một giá trị độ sâu
Shader mặt đất cũng chèn vào MeshStandardMaterial qua onBeforeCompile. Mọi trạng thái ướt suy ra từ một giá trị độ sâu:
float depth = 0.5 - mk.r + (sn - 0.5) * 0.11 + ... + groove * 0.04 + crack * 0.02 - paint * 0.01;
float puddle = smoothstep(0.0, 0.035, depth) * uWetG; // mặt gương
float soak = smoothstep(-bandW, 0.0, depth) * uWetG; // dải ngấm 10–30 cm
float fringe = smoothstep(-0.65, -0.02, depth); // quầng ẩm khoảng 1 m- Albedo tối dần theo độ ẩm, độ ngấm và độ sâu vũng.
- Roughness đi từ 0,74 (khô) qua 0,42 (ẩm), 0,2 (ngấm) xuống 0,055 (nước đọng).
- Normal phẳng lại trong vũng.
- Gợn mưa: mỗi ô lưới mỗi chu kỳ sinh một giọt, ba lớp ô khác kích thước; ngoài 10–34 m thay bằng cộng roughness.
- Khe co giãn vẽ bằng SDF, khử răng cưa bằng
fwidth. - Vệt dầu chỉ làm tối và bóng hơn; không có hiệu ứng óng ánh.

Phản chiếu phẳng
- Camera gương qua mặt phẳng y = 0 với oblique near plane.
- Target HalfFloat có mipmap; kích thước bằng hệ số
planarcủa quality nhân độ phân giải. - Độ nhoè lấy từ mip chain theo roughness; UV méo theo normal.
- Kết quả ghi đè biến
radiancecủa IBL, nên phần còn lại vẫn là BRDF chuẩn. - Biển, mưa, splash và súng bị ẩn khi render phản chiếu.
Biển sóng Gerstner
Biển dùng lưới tự dựng, dày ở gần và thưa dần ra xa. Vertex shader dùng 4 sóng Gerstner; fragment shader dùng slope giải tích của 4 sóng đó cộng thêm 7 sóng mịn. Bọt tính theo khoảng cách tới tường cảng và SDF của thân tàu. Công thức sóng có bản CPU để thuyền dập dềnh khớp với mặt nước.

Mưa 9000 hạt không có state trên CPU
Khoảng 9000 quad instanced, nhân với hệ số mưa của quality, không giữ state nào trên CPU. Vị trí tính hoàn toàn trong vertex shader rồi quấn quanh camera:
p = aSeed.xyz * uBox + vel * uTime;
p = mod(p - cameraPosition + 0.5 * uBox, uBox) - 0.5 * uBox + cameraPosition;- Bề rộng hạt có mức tối thiểu theo pixel, bù lại bằng alpha.
- Hạt bị loại trong các AABB nội thất, trừ đúng các ô mái thủng mà level xuất ra.
- Splash đọc mask vũng nước ngay trong vertex shader để dồn về chỗ có nước.
VFX chiến đấu gói trong 5 draw call
Toàn bộ hiệu ứng chiến đấu nằm trong 5 draw call: decal, debris, vỏ đạn, hạt lit và hạt additive.
- Atlas sinh bằng code: atlas khói lưu normal ở RG, độ dày ở B, mật độ ở A; atlas additive chứa glow, streak, sao nòng, ring, lửa và giọt cho tracer; decal vết đạn gồm ba canvas màu, ORM và height đổi sang normal.
- Pool hạt: hai pool struct-of-arrays, 1400 hạt lit có sort và 3040 hạt additive không sort. Mô phỏng trên CPU gồm drag, gravity, turbulence và va chạm sàn.
- Dựng quad ở vertex shader theo 3 kiểu: billboard, kéo dài theo vận tốc, nằm theo bề mặt.
- Khói: soft particle đọc depth; chiếu sáng bằng normal cầu ảo cộng normal atlas, wrap diffuse, forward scatter; độ thấy mặt trời lấy từ một raycast cho mỗi hiệu ứng.
- Tracer: 48 slot, mỗi tracer 3 quad additive có giới hạn bề rộng theo pixel.
- Debris:
InstancedMesh256 hộp méo. - Vỏ đạn:
InstancedMesh60 vỏ từLatheGeometry, tích phân quaternion, nảy tối đa 4 lần và phát sự kiện cho audio. - Vết đạn: 160 quad xoay vòng, căn theo normal, không chiếu.
- Ngân sách: cố định, không scale theo quality; chỉ có LOD ở 55 m.

Súng, tay và kẻ địch
Súng và tay dựng thủ tục
- Súng: vài trăm primitive gom vào 7 bucket: 4 bucket tĩnh, cộng bolt, băng đạn và viên đạn. Thân súng dùng profile extrude có lỗ; nòng và ống giảm thanh dùng lathe; ray picatinny là vòng lặp 49 răng.
- Tay: loft theo vòng đỉnh, không skin. Khớp ngón dùng vòng slerp và cắt theo mặt phân giác để các đốt không xuyên nhau. Ngón bóp cò nội suy giữa hai pose bake sẵn.
- Kính ngắm: reticle tính theo góc lệch khỏi trục nòng chứ không theo UV, nên không có parallax.
- Chớp nòng: 8 quad, cánh sao sinh thủ tục trong fragment shader, seed đổi mỗi phát.

Chuyển động súng bằng 9 lò xo
Độ lắc, giật và nghiêng của súng do 9 lò xo giảm chấn đảm nhận, kích bằng xung vận tốc. Bộ tích phân chia bước 8 ms khi frame dài để lò xo không nổ:
function ma(s, target, dt) {
let n = dt > 0.012 ? Math.ceil(dt / 0.008) : 1, h = dt / n;
for (let o = 0; o < n; o++) { s.v += ((target - s.x) * s.k - s.v * s.c) * h; s.x += s.v * h; }
return s.x;
}Reload là keyframe viết tay cho tay trái, nội suy smootherstep. Các sự kiện tháo băng, lắp băng, lên đạn bắn ra theo ngưỡng thời gian.
Rig và animation kẻ địch bằng code
- Rig bằng code: khai báo bone theo tên và bone cha; mỗi mảnh hình học gán cứng vào một bone với weight 1, kèm AO theo
normal.y. - Instance: geometry dựng một lần; mỗi con có
SkinnedMeshvàSkeletonriêng nhưng dùng chung geometry. - Không có AnimationMixer hay clip: controller đặt thẳng rotation, position, scale của bone mỗi frame. Bật tắt chi tiết bằng cách scale bone về gần 0.
- Hound: IK chân giải tích 3 bậc tự do; nhấc cặp chân chéo khi sai lệch giữa chân đang đặt và vị trí dự đoán vượt ngưỡng; thân nghiêng theo độ cao bốn chân qua lò xo.
- Gull: bay bằng điều khiển PD, nghiêng thân theo gia tốc lệnh; đĩa mờ của rotor chỉ là một ring tĩnh.
- Hit test giải tích: sphere, capsule, ellipsoid; không raycast vào mesh.


Âm thanh tổng hợp bằng DSP
- Thư viện DSP: khoảng 20 primitive trên
Float32Array: noise trắng, biquad RBJ, biquad biến thiên theo thời gian, envelope mũ, modal synthesis, chirp, grain, tanh. - Công thức: mỗi âm là các lớp cộng lại. Tiếng súng gồm crack, thump, body và tiếng cơ khí; đuôi vang ngoài trời và trong nhà được bake sẵn thành hai buffer stereo.
- Seed cố định: hash của tên âm cộng chỉ số biến thể, nên buffer giống nhau mỗi phiên. Lúc phát có round-robin và lệch pitch ngẫu nhiên.
- Loop liền mạch: crossfade đuôi vào đầu, làm tròn tần số theo độ dài loop, chạy trước một pass để khởi tạo state bộ lọc.
- Reverb: impulse response cũng tổng hợp bằng code, cho hai
ConvolverNodecrossfade theo vùng trong nhà. - Nhạc: 5 stem phát đồng bộ, chỉ đổi gain theo cường độ giao chiến.
- Lịch sinh: 19 buffer sinh lúc boot, phần còn lại chia lát 6 ms bằng generator, không dùng Worker.
- Master: compressor rồi soft-clip tanh; pool 45 voice có ưu tiên và cướp voice.
Bài học khi tự làm game Three.js procedural
Không cần sao chép cả Saltline để hưởng lợi từ cách nó tổ chức. Tám nguyên tắc dưới đây áp dụng được cho hầu hết game web nhỏ:
- Gộp theo material, không theo vật thể. Bake transform vào geometry rồi merge mỗi bucket thành một mesh, chỉ giữ riêng phần cần chuyển động. Số draw call khi đó bám theo số material.
- Bake texture một lần, đừng tính lại mỗi frame. Viết
surf(uv)bằng GLSL, render vào render target nhiều attachment, copy sang texture có mipmap, và chỉ bake họ nào đang được dùng. - Dùng alpha làm mặt nạ tint. Một bộ texture sơn phục vụ nhiều màu, không phải nhân bản texture theo màu.
- Vá
MeshStandardMaterialthay vì viết shader từ đầu.onBeforeCompilecho phép thêm ướt, biến thiên vĩ mô, triplanar mà vẫn giữ bóng, IBL và BRDF của Three.js. - Đẩy hiệu ứng nền xuống vertex shader. Mưa, chim, đèn nháy không cần state trên CPU; chỉ thứ ảnh hưởng gameplay mới chạy pool CPU.
- Đặt trần ngân sách cho VFX. Pool hạt, slot tracer và số vết đạn đều có giới hạn và xoay vòng khi đầy, nên frame time không phình theo mức độ giao chiến.
- Seed mọi thứ. RNG có seed cho prop, decal và âm thanh giúp mỗi lần chạy giống nhau, dễ debug và dễ cache.
- Đo frame time rồi tự điều chỉnh. Chọn preset theo GPU lúc khởi động, sau đó co giãn độ phân giải theo frame time và hạ preset khi cần.
Phạm vi và giới hạn của bài phân tích
- Đây là đọc mã tĩnh trên bundle đã định dạng; game không bị chỉnh sửa.
- Phần vật liệu, decal, bầu trời, bóng, hậu kỳ và renderer được đọc trực tiếp. Props, level, mặt đất, FX, súng, kẻ địch và âm thanh do nhiều agent AI đọc song song, có đối chiếu lại code ở một số điểm trong mỗi phần, không phải mọi dòng.
- Chưa đếm số tam giác của từng asset; các con số tổng ở đầu bài đo lúc chạy.
- Việc Fresnel của vũng nước và biển đi qua BRDF chuẩn của Three.js là suy luận từ cấu trúc shader, chưa đối chiếu với shader chunk r180.
- Ảnh chụp từ bản chạy tại meowsolo.com/gameaaa ở preset high, camera được đặt bằng code để lấy góc nhìn. Texture, decal và atlas nhãn được trích thẳng từ GPU và canvas lúc game chạy; bốn ô tô màu của texture sơn được ghép lại theo công thức tint.
Câu hỏi thường gặp
Saltline có dùng file model hay texture nào không?
Không. Bundle không kèm file GLB, FBX, ảnh hay âm thanh. Model, texture, hiệu ứng và âm thanh đều được sinh lúc chạy; thứ duy nhất tải thêm là webfont.
Saltline dùng phiên bản Three.js nào?
Three.js r180 trên WebGL2, viết bằng JavaScript thuần. Bản chạy tại meowsolo.com/gameaaa không cần bước build hay thư viện cài thêm.
Có nên sinh asset bằng code cho một game Three.js nhỏ?
Sinh asset bằng code giúp bundle nhỏ và dễ tạo biến thể, đổi lại là thời gian khởi động và công viết shader. Saltline cân bằng bằng cách bake một lần, cache theo seed, chỉ sinh thứ đang được dùng và chia việc tổng hợp âm thanh thành lát 6 ms để không chặn khung hình.
Muốn tự kiểm chứng, hãy mở Saltline, vào Settings đổi qua lại bốn mức chất lượng và đứng cạnh một vũng nước khi trời mưa. Meow Solo cũng làm hai game 3D khác chơi ngay trên trình duyệt: Mellow Miles và Tiệm Cà Phê Bất Ổn.