Building a High‑Performance 818 3D Runner with Oasis Engine: Design & Optimization
This article shares a detailed post‑mortem of the 818 3D runner game developed with Oasis Engine, covering track and character design, shader tricks, asset reduction, memory and performance optimizations, code structure, and troubleshooting of crashes and overheating, offering practical insights for developers building similar high‑traffic mobile games.
Preface
The 818 promotion used a parkour game as the main mechanic. Using the latest Oasis engine version, we achieved an average FPS of 58.85 for millions of users.
Game Design
Track Design
The track is modeled in two parts:
Central road
Side flower beds
The central road is a 24.5 m radius arc with a 12° central angle. The mesh is static; we replace its texture to create the running surface.
The side flower beds are positioned and rotated based on the road radius and angle. A circular motion component moves them backward and rotates them around the X‑axis, looping when they exit the view.
Lighting
All models use baked lighting to avoid runtime shader calculations. Baking improves performance but can cause visual inconsistencies if not unified across assets.
Baked result
Unbaked result
Coins
Coins are pooled; 20 are pre‑generated for a typical run. Their positions are calculated from the road radius and angle. If the pool empties, new coins are created dynamically.
Character
The character is a glTF model with multiple animation clips. When colliding with an obstacle, the running animation switches to a stunned one for two seconds.
Character Shadow Implementation
Shadows are generated via planar projection in the vertex shader, projecting each vertex onto the X‑O‑Z plane based on a light direction. The core shader code:
attribute vec4 POSITION;
vec3 ShadowProjectPos(vec4 vertPos) {
vec3 worldPos = (u_modelMat * vertPos).xyz;
float lightHeight = u_lightDirAndHeight.w;
vec3 lightDir = normalize(u_lightDirAndHeight.xyz);
vec3 shadowPos;
shadowPos.y = min(worldPos.y , lightHeight);
shadowPos.xz = worldPos.xz - lightDir.xz * max(0.0, worldPos.y - lightHeight) / lightDir.y;
return shadowPos;
}
void main() {
vec4 position = vec4(POSITION.xyz, 1.0);
vec3 shadowPos = ShadowProjectPos(position);
gl_Position = u_VPMat * vec4(shadowPos, 1.0);
vec3 center = vec3(u_modelMat[3].x , u_lightDirAndHeight.w , u_modelMat[3].z);
float falloff = 0.5 - clamp(distance(shadowPos , center) * u_planarShadowFalloff, 0.0, 1.0);
color = u_planarShadowColor;
color.a *= falloff;
}Because the road is curved, the shadow plane is offset downward ~0.3 m to avoid penetration.
Character Clone Effect
The clone effect uses a vertex shader that adds noise‑based X‑offset and a uniform controlling opacity. Core shader snippets:
// Vertex shader
uniform float glitch; // jitter amplitude
uniform float iTime; // game time
float noise(float value) { return fract(sin(dot(vec2(value, 2), vec2(12.9898, 78.233)))); }
void main() {
...
gl_Position.x += noise(iTime + gl_Position.y) * glitch;
}
// Fragment shader
uniform float alpha;
void main() { gl_FragColor = vec4(1.,1.,1.,alpha); }Scene Optimizations
Images
Textures are resized to the minimum needed size. For a 750×1628 screen, a model occupying one‑third width needs at most 512×512 texture; larger sizes waste memory.
Repeated patterns (e.g., road tiles) can be as small as 128×128 and tiled in the shader.
Texture Atlases
Combining many small images into an atlas reduces HTTP requests and draw calls, improving load speed and rendering performance.
Models
We aggressively reduce polygon count: invisible faces are removed, low‑poly versions are used for hair and accessories, and pure‑color parts use vertex colors instead of textures. The extreme case totals about 122 000 faces, which can be trimmed further by ~40 % with more aggressive culling.
Skeleton Animation
Export keyframes directly (≈50 per animation) instead of baked skeletal animation to keep data lightweight.
Texture Compression
Compressed textures can cut memory usage by ~75 % but may introduce ~15 % visual loss and increase load time. Use them only when memory pressure is evident.
Shader Optimization
Remove unused code paths and include only necessary shader modules. Replace heavy Perlin noise with a compact version:
float hash(vec3 p){ p = fract(p*0.3183099+.1); p *= 17.0; return fract(p.x*p.y*p.z*(p.x+p.y+p.z)); }
float noise(in vec3 x){ vec3 i = floor(x); vec3 f = fract(x); f = f*f*(3.0-2.0*f); return mix(mix(mix(hash(i+vec3(0,0,0)),hash(i+vec3(1,0,0)),f.x),mix(hash(i+vec3(0,1,0)),hash(i+vec3(1,1,0)),f.x),f.y),mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)),f.x),mix(hash(i+vec3(0,1,1)),hash(i+vec3(1,1,1)),f.x),f.y),f.z); }General Coding Tips
Prefer multiplication over division.
Use bit‑shifts for powers‑of‑two scaling.
Prefer WeakMap for weak references.
Clear arrays with
arr.length = 0.
Avoid
for…inand
for…ofloops; use indexed
forloops.
Destroy objects and nullify references to aid GC.
Development Process
Resource Loading
Essential assets (character, road, background, coins) are loaded first; optional effects load after the 321 countdown, reducing initial wait time.
Scene Assembly
Positions are fine‑tuned in the editor, then the resulting transforms are hard‑coded for runtime efficiency.
Logic Development
The game is split into modules: main flow control, event bus, tool manager, resource manager, and gesture handling. Example pseudocode:
const GameControl = {
fadeRate: 0,
gameTime: 0,
wholeTime: 10000,
deltaTime: 0,
toolTime: 0,
initScene() { /* init and start 321 */ },
start() { /* start run */ },
stop() { /* end animation and clean up */ },
onUpdate(deltaTime) { this.deltaTime = deltaTime; this.gameTime += deltaTime; this.toolTime += deltaTime * this.fadeRate; },
createObjOnRoad() { /* spawn coins, obstacles, accelerators */ }
};
class GameToolManager { setShield(b) {} setAccelerate(b) {} setCharacterClone(b) {} }
const resources = { coinPool: [], barricadePool: [], assets: { character: mesh, road: mesh2, /* ... */ }, onLoad() { GameControl.initScene(); GameEvent.dispatch('onLoad'); }, loadImportantAssets(list) { let assets = await engine.resourceManager.load(list); this.onLoad(); } };Gesture handling listens for touch events and moves the character left or right when the swipe exceeds a threshold.
let isDown = false, isSwipe = false, startX = 0;
function onTouchStart(e){ if(!GameState.interactive) return; isDown = true; isSwipe = false; startX = e.targetTouches[0].clientX; }
function onTouchMove(e){ if(!isDown) return; let curX = e.targetTouches[0].clientX; let deltaX = curX - startX; if(deltaX > 20){ /* move right */ isDown = false; } else if(deltaX < -20){ /* move left */ isDown = false; } }
function onTouchEnd(){ isDown = false; }Collision detection for coins uses simple AABB checks instead of full physics simulation.
class EatCoinScript extends Script {
onUpdate(time) {
// if |coin.position.z| < 0.5 && |coin.position.x - character.position.x| < 0.5 then collect
}
}Code Optimizations
Shader Optimizations
Strip unused includes and use the compact noise function shown earlier.
Other Recommendations
Replace division with multiplication where possible.
Use left/right shifts for powers of two.
Prefer WeakMap for weak references.
Clear arrays via
arr.length = 0.
Avoid
for…in/
for…ofloops.
Destroy objects and nullify references after use.
Issues Encountered
Memory Leaks & Overheating
The only memory leak stemmed from Oasis’s Lottie plugin destroy method (fixed in the latest version). React component leaks were solved by upgrading to React 18 and optimizing renders.
Overheating was caused by frequent events sent to React components, triggering full re‑renders. Optimizing those components eliminated the heat issue.
WebGL Crashes
Crash rate was 0.027 % after launch. Crashes can be caused by memory exhaustion, oversized textures (>2048 px), or external factors. Proper asset sizing and diligent memory management mitigate most crashes.
Conclusion
For large‑scale promotional games, stability outweighs visual complexity. Careful resource optimization, staged loading, and robust fallback strategies ensure smooth performance even on weak networks and low‑end devices.
Alipay Experience Technology
Exploring ultimate user experience and best engineering practices
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.