Mổ xẻ Saltline: game FPS Three.js sinh mọi asset bằng code

Saltline nặng 1,4 MB mà không có file model, texture hay âm thanh nào. Mổ xẻ cách game FPS Three.js này sinh hình học, vật liệu PBR, VFX và âm thanh bằng code.

Dũng Nguyễn
11/11/2026
19 phút đọc

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.

Màn hình tiêu đề Saltline: cảng container dưới mưa, nắng chiều xuyên qua cần cẩu, toàn bộ cảnh được sinh bằng code

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.

Góc nhìn thứ nhất trong Saltline: khẩu súng giữa hai dãy container, mặt đường ướt phản chiếu cần cẩu và nắng chiều

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ậtCách làm
UV theo métBox 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 box24 đỉ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 boxBoxGeometry 3×3×3, đỉnh ở cạnh và góc được chiếu lên bán kính bo quanh hộp lõi
LoftProfile 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 containerProfile hình thang chỉ phát đỉnh tại 4 điểm gãy mỗi chu kỳ
Vết mópDanh sách vết có seed, độ lún cộng dồn với falloff (1−d²)²
LatheNormal 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
ExtrudeNormal 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ápTubeGeometry trên CatmullRomCurve3, số đoạn theo chiều dài
Hàn normalCộ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.

Cận cảnh vách container trong Saltline: tôn hình thang, vết móp, gỉ và logo hãng tàu đều sinh bằng code

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.
Bạt phủ hàng trong Saltline: mặt bạt ôm theo từng thùng bên dưới, có nếp gấp và dây chằng bám mặt bạt

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.

Ba đầu ra của một lần bake texture thép gỉ trong Saltline: albedo, normal map và ORM gồm AO, roughness, metalness

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.

Tám texture procedural của Saltline bake bằng GLSL: thép gỉ, bê tông, gỗ, carton, thép chống trượt, lưới thép, vạch cảnh báo, 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.

Texture sơn container của Saltline: kênh màu, mặt nạ alpha và cùng một texture tô thành bốn màu sơn đỏ, xanh dương, xanh lá, 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.

Bộ decal Canvas2D của Saltline: biển cảnh báo, logo hãng tàu, biển CSC, chữ stencil, số, vệt gỉ, vệt dầu và vết cháy

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.

Atlas nhãn 2048 px của Saltline xếp bằng shelf packing: số hiệu container, tải trọng, mã lô và tên relay

Á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ầu trời của Saltline vẽ bằng một fragment shader: tầng mây fbm, khe nắng và mặt trời sau dãy container

Bóng nắng PCSS trên một shadow map

  • Một DirectionalLight vớ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 getShadow trong 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 PointLight dù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:

  1. Depth tuyến tính ở nửa độ phân giải.
  2. AO 12 tap, normal dựng lại từ depth, lọc bilateral.
  3. 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.
  4. Upsample bilateral rồi ghép AO và sương vào cảnh.
  5. Motion blur của camera bằng reprojection theo depth.
  6. Bloom 6 tầng với Karis average ở tầng đầu, thêm vệt anamorphic.
  7. Auto exposure, có kiểm tra mặt trời bị che để điều khiển flare.
  8. Pass màu: chromatic aberration, lens dirt, flare, tonemap ACES kéo về AgX, split-tone, vignette, hiệu ứng bị thương.
  9. 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.

Nhà kho trong Saltline: tia nắng volumetric xuyên qua cửa lớn, kệ hàng và thùng IBC trong không khí mờ

Preset chất lượng và độ phân giải động

PresetScaleMSAAShadowTextureBước volumetricPlanarMotion blurMưa
low0.70204851212tắttắt0.5
medium0.85220481024160.5tắt0.8
high1440961024240.5bật1
ultra1440962048401bật1

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.

Bảng Settings của Saltline với bốn mức Graphics quality low, medium, high, ultra cùng độ nhạy chuột và FOV

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 Uint8Array của mask trả lời isPuddle và wetnessAt cho 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.
Mặt đường ướt trong Saltline: vũng nước phản chiếu container, cần cẩu và bầu trời, quầng ẩm loang quanh mép vũng

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ố planar củ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 radiance củ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.

Biển trong Saltline nhìn từ mép cảng: sóng Gerstner lấp lánh dưới nắng chiều, tàu hàng mờ dần ở đường chân trời

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: InstancedMesh 256 hộp méo.
  • Vỏ đạn: InstancedMesh 60 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.
Bắn liên thanh vào vách container trong Saltline: chớp nòng, tia lửa additive, tracer và vỏ đạn văng ra

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.
Ngắm qua kính red dot trong Saltline: reticle tính theo góc lệch khỏi trục nòng, ray picatinny có răng đánh số

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ó SkinnedMesh và Skeleton riê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.
Hound, robot bốn chân của Saltline, đứng trên mặt đường ướt: chân giải IK, rig dựng bằng code, không có animation clip
Gull, drone bốn rotor của Saltline, bay giữa các dãy container; đĩa mờ rotor là một ring tĩnh

Â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 ConvolverNode crossfade 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ỏ:

  1. 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.
  2. 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.
  3. 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.
  4. Vá MeshStandardMaterial thay vì viết shader từ đầu. onBeforeCompile cho 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.
  5. Đẩ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.
  6. Đặ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.
  7. 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.
  8. Đ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.

Three.js
Procedural generation
WebGL
GLSL
Game 3D trình duyệt
Làm game
Xem sản phẩm của Meow Solo
Tất cả bài viết

Bạn có bài toán cần xử lý?

Hãy chia sẻ bài toán và kết quả bạn mong muốn. Chúng ta sẽ cùng tìm một cách hợp tác mà đôi bên đều có lợi.
Liên hệ ngay
Hoặc email trực tiếp
mrdee0428@gmail.com
Facebook
Telegram