WebAssembly ile 2D Oyun Motoru Oluşturma
Oyun geliştirme dünyası artık sadece masaüstü veya mobil platformlarla sınırlı değil. Tarayıcı üzerinde çalışan, native performansa yakın hız sunan oyunlar artık bir hayal değil. WebAssembly’nin bize verdiği güç sayesinde, C++ ile yazdığımız bir 2D oyun motoru doğrudan tarayıcıda koşabiliyor. Ben de bir sistem yöneticisi olarak bu konuya biraz farklı açıdan bakıyorum: altyapı, deployment pipeline’ı ve performans optimizasyonu. Bu yazıda sıfırdan bir 2D oyun motoru inşa edip, bunu WebAssembly ile tarayıcıya taşıyacağız.
Neden WebAssembly ile Oyun Motoru?
JavaScript ile oyun yazmak mümkün ama gerçek zamanlı fizik hesaplamaları, sprite rendering ve collision detection gibi yoğun işlemler söz konusu olduğunda JS’in tek thread yapısı ve garbage collector gecikmesi ciddi darboğazlar yaratıyor. WebAssembly ise derlenmiş binary format kullandığı için bu hesaplamaları çok daha verimli yapıyor.
Gerçek dünyadan bir örnek verelim: Bir indie oyun stüdyosunun “Steam’e ek olarak tarayıcıda da çalışsın” dediği durumu düşünün. Emscripten toolchain ile C++ kodunu WASM’a derleyip, aynı kod tabanını hem masaüstü hem web için kullanabilirsiniz. Bu hem maliyet hem de maintainability açısından çok mantıklı.
Geliştirme Ortamını Hazırlama
Önce toolchain’i kuralım. Emscripten SDK olmadan bu iş yürümez.
# Emscripten SDK kurulumu
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
# En son versiyonu çek ve aktif et
./emsdk install latest
./emsdk activate latest
# Environment variable'ları ayarla
source ./emsdk_env.sh
# Kurulumu doğrula
emcc --version
# SDL2 bağımlılıklarını kontrol et
emcc -s USE_SDL=2 --version
Sisteminizde ayrıca CMake ve Python3 olması gerekiyor. Ubuntu tabanlı sistemlerde:
sudo apt update && sudo apt install -y
cmake
python3
python3-pip
build-essential
libsdl2-dev
git
# Node.js ile local test sunucusu
npm install -g serve
Proje Yapısını Oluşturma
İyi bir proje yapısı, ileride başınızı ağrıtmaz. Özellikle hem native hem WASM hedefi varsa build sistemi kritik önem taşıyor.
mkdir -p wasm-engine/{src,assets,web,build-native,build-wasm}
cd wasm-engine
# Dizin yapısı
tree .
# ├── src/
# │ ├── engine/
# │ │ ├── renderer.cpp
# │ │ ├── physics.cpp
# │ │ ├── input.cpp
# │ │ └── game_loop.cpp
# │ ├── game/
# │ │ └── main.cpp
# │ └── CMakeLists.txt
# ├── assets/
# │ ├── sprites/
# │ └── sounds/
# └── web/
# └── index.html
CMakeLists.txt dosyasını oluşturalım:
cat > src/CMakeLists.txt << 'EOF'
cmake_minimum_required(VERSION 3.16)
project(WasmEngine)
set(CMAKE_CXX_STANDARD 17)
# Platform tespiti
if(EMSCRIPTEN)
message(STATUS "Building for WebAssembly target")
set(CMAKE_EXECUTABLE_SUFFIX ".js")
set(EMCC_FLAGS
"-s USE_SDL=2"
"-s USE_SDL_IMAGE=2"
"-s SDL2_IMAGE_FORMATS='["png","jpg"]'"
"-s WASM=1"
"-s ALLOW_MEMORY_GROWTH=1"
"-s MAXIMUM_MEMORY=256MB"
"-s EXPORTED_FUNCTIONS=['_main','_update_game_state']"
"-s EXPORTED_RUNTIME_METHODS=['ccall','cwrap']"
"--preload-file ../assets@/assets"
)
string(REPLACE ";" " " EMCC_FLAGS_STR "${EMCC_FLAGS}")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} ${EMCC_FLAGS_STR}")
else()
find_package(SDL2 REQUIRED)
endif()
file(GLOB_RECURSE SOURCES "*.cpp")
add_executable(wasm_engine ${SOURCES})
if(EMSCRIPTEN)
target_compile_options(wasm_engine PRIVATE -O3)
else()
target_link_libraries(wasm_engine SDL2::SDL2)
endif()
EOF
Oyun Motorunun Çekirdeği
Şimdi asıl işe gelelim. Game loop yapısı her oyun motorunun kalbidir. Fixed timestep kullanmak, farklı cihazlarda tutarlı davranış sağlar.
// src/engine/game_loop.cpp
#include <SDL2/SDL.h>
#include <functional>
#include <chrono>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#include <emscripten/html5.h>
#endif
class GameLoop {
private:
static const int TARGET_FPS = 60;
static const int FIXED_TIMESTEP_MS = 1000 / TARGET_FPS;
bool running;
Uint32 lastTime;
Uint32 accumulator;
std::function<void(float)> updateCallback;
std::function<void()> renderCallback;
public:
GameLoop() : running(false), lastTime(0), accumulator(0) {}
void setUpdateCallback(std::function<void(float)> cb) {
updateCallback = cb;
}
void setRenderCallback(std::function<void()> cb) {
renderCallback = cb;
}
static void mainLoopCallback(void* arg) {
GameLoop* loop = static_cast<GameLoop*>(arg);
loop->tick();
}
void tick() {
Uint32 currentTime = SDL_GetTicks();
Uint32 deltaTime = currentTime - lastTime;
lastTime = currentTime;
// Delta time'i cap'le, debug breakpoint durumlarinda
// buyuk spike'lari onlemek icin
if (deltaTime > 250) deltaTime = 250;
accumulator += deltaTime;
while (accumulator >= FIXED_TIMESTEP_MS) {
if (updateCallback) {
updateCallback(FIXED_TIMESTEP_MS / 1000.0f);
}
accumulator -= FIXED_TIMESTEP_MS;
}
if (renderCallback) {
renderCallback();
}
}
void start() {
running = true;
lastTime = SDL_GetTicks();
#ifdef __EMSCRIPTEN__
// WASM'da blocking loop kullanamayiz
// requestAnimationFrame benzeri calisir
emscripten_set_main_loop_arg(mainLoopCallback, this, 0, 1);
#else
while (running) {
tick();
SDL_Delay(1); // CPU'yu bosalt
}
#endif
}
void stop() { running = false; }
};
Renderer Sistemi
2D renderer için SDL2’nin texture API’sini kullanacağız. Sprite batching önemli bir optimizasyon tekniğidir.
// src/engine/renderer.cpp
#include <SDL2/SDL.h>
#include <SDL2/SDL_image.h>
#include <unordered_map>
#include <string>
#include <vector>
struct Sprite {
SDL_Texture* texture;
int width;
int height;
};
struct DrawCommand {
SDL_Texture* texture;
SDL_Rect src;
SDL_Rect dst;
double angle;
Uint8 alpha;
};
class Renderer {
private:
SDL_Window* window;
SDL_Renderer* sdlRenderer;
std::unordered_map<std::string, Sprite> textureCache;
std::vector<DrawCommand> drawQueue;
// Render istatistikleri
int drawCallCount;
int cachedTextureCount;
public:
Renderer(const char* title, int width, int height)
: drawCallCount(0), cachedTextureCount(0) {
window = SDL_CreateWindow(
title,
SDL_WINDOWPOS_CENTERED,
SDL_WINDOWPOS_CENTERED,
width, height,
SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE
);
sdlRenderer = SDL_CreateRenderer(
window, -1,
SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC
);
// Blend mode varsayilan ayar
SDL_SetRenderDrawBlendMode(sdlRenderer, SDL_BLENDMODE_BLEND);
IMG_Init(IMG_INIT_PNG | IMG_INIT_JPG);
}
Sprite* loadSprite(const std::string& path) {
// Cache hit kontrolu
auto it = textureCache.find(path);
if (it != textureCache.end()) {
return &it->second;
}
SDL_Surface* surface = IMG_Load(path.c_str());
if (!surface) {
SDL_Log("Sprite yuklenemedi: %s", path.c_str());
return nullptr;
}
Sprite sprite;
sprite.texture = SDL_CreateTextureFromSurface(sdlRenderer, surface);
sprite.width = surface->w;
sprite.height = surface->h;
SDL_FreeSurface(surface);
textureCache[path] = sprite;
cachedTextureCount++;
return &textureCache[path];
}
void queueDraw(Sprite* sprite, int x, int y,
float scaleX = 1.0f, float scaleY = 1.0f,
double angle = 0.0, Uint8 alpha = 255) {
DrawCommand cmd;
cmd.texture = sprite->texture;
cmd.src = {0, 0, sprite->width, sprite->height};
cmd.dst = {
x, y,
static_cast<int>(sprite->width * scaleX),
static_cast<int>(sprite->height * scaleY)
};
cmd.angle = angle;
cmd.alpha = alpha;
drawQueue.push_back(cmd);
}
void render() {
SDL_SetRenderDrawColor(sdlRenderer, 20, 20, 30, 255);
SDL_RenderClear(sdlRenderer);
// Queue'daki tum draw command'lari isle
drawCallCount = 0;
for (const auto& cmd : drawQueue) {
SDL_SetTextureAlphaMod(cmd.texture, cmd.alpha);
SDL_RenderCopyEx(
sdlRenderer, cmd.texture,
&cmd.src, &cmd.dst,
cmd.angle, nullptr,
SDL_FLIP_NONE
);
drawCallCount++;
}
drawQueue.clear();
SDL_RenderPresent(sdlRenderer);
}
int getDrawCallCount() const { return drawCallCount; }
~Renderer() {
for (auto& pair : textureCache) {
SDL_DestroyTexture(pair.second.texture);
}
SDL_DestroyRenderer(sdlRenderer);
SDL_DestroyWindow(window);
IMG_Quit();
}
};
Fizik ve Collision Sistemi
Basit AABB (Axis-Aligned Bounding Box) collision sistemi kuralım. Platformer oyunlar için yeterince güçlü.
// src/engine/physics.cpp
#include <vector>
#include <functional>
#include <cmath>
struct Vec2 {
float x, y;
Vec2(float x = 0, float y = 0) : x(x), y(y) {}
Vec2 operator+(const Vec2& o) const { return {x + o.x, y + o.y}; }
Vec2 operator*(float s) const { return {x * s, y * s}; }
};
struct AABB {
float x, y, w, h;
bool intersects(const AABB& other) const {
return x < other.x + other.w &&
x + w > other.x &&
y < other.y + other.h &&
y + h > other.y;
}
// Penetrasyon vektorunu hesapla
Vec2 getPenetration(const AABB& other) const {
float overlapX = std::min(x + w, other.x + other.w) -
std::max(x, other.x);
float overlapY = std::min(y + h, other.y + other.h) -
std::max(y, other.y);
if (overlapX < overlapY)
return {overlapX * (x < other.x ? -1.0f : 1.0f), 0};
else
return {0, overlapY * (y < other.y ? -1.0f : 1.0f)};
}
};
class PhysicsBody {
public:
Vec2 position;
Vec2 velocity;
Vec2 acceleration;
AABB bounds;
bool isStatic;
bool isGrounded;
float mass;
float friction;
static const float GRAVITY;
PhysicsBody(float x, float y, float w, float h, bool isStatic = false)
: position(x, y), velocity(0, 0), acceleration(0, 0),
isStatic(isStatic), isGrounded(false), mass(1.0f), friction(0.85f) {
bounds = {x, y, w, h};
}
void update(float dt) {
if (isStatic) return;
// Yerçekimi uygula
acceleration.y += GRAVITY;
// Euler entegrasyonu
velocity = velocity + acceleration * dt;
// Yatay friction
velocity.x *= friction;
// Velocity limit
const float MAX_VELOCITY = 800.0f;
if (std::abs(velocity.y) > MAX_VELOCITY)
velocity.y = velocity.y > 0 ? MAX_VELOCITY : -MAX_VELOCITY;
position = position + velocity * dt;
// Bounds'u pozisyonla güncelle
bounds.x = position.x;
bounds.y = position.y;
// Accelerasyonu sifirla (kuvvetler her frame uygulanir)
acceleration = {0, 0};
isGrounded = false;
}
void applyForce(Vec2 force) {
if (!isStatic) {
acceleration.x += force.x / mass;
acceleration.y += force.y / mass;
}
}
};
const float PhysicsBody::GRAVITY = 980.0f; // pixels/s^2
Build Pipeline ve Deployment
İşte sysadmin tarafı burada devreye giriyor. CI/CD pipeline ile otomatik build ve deployment.
#!/bin/bash
# build.sh - Hem native hem WASM icin build scripti
set -euo pipefail
PROJECT_ROOT=$(dirname "$0")
BUILD_TYPE="${1:-wasm}" # native veya wasm
echo "=== WasmEngine Build Pipeline ==="
echo "Build hedefi: $BUILD_TYPE"
build_wasm() {
echo "[WASM] Emscripten environment yukleniyor..."
source "$HOME/emsdk/emsdk_env.sh" 2>/dev/null
mkdir -p "$PROJECT_ROOT/build-wasm"
cd "$PROJECT_ROOT/build-wasm"
emcmake cmake ../src
-DCMAKE_BUILD_TYPE=Release
-DCMAKE_CXX_FLAGS="-O3 -flto"
emmake make -j$(nproc)
# Output dosyalarini web dizinine kopyala
cp wasm_engine.js wasm_engine.wasm "$PROJECT_ROOT/web/"
# Asset'leri gzip ile sikistir
find "$PROJECT_ROOT/web" -name "*.wasm" -exec gzip -9 -k {} ;
echo "[WASM] Build tamamlandi!"
ls -lh "$PROJECT_ROOT/web/"
}
build_native() {
echo "[NATIVE] Native build basliyor..."
mkdir -p "$PROJECT_ROOT/build-native"
cd "$PROJECT_ROOT/build-native"
cmake ../src -DCMAKE_BUILD_TYPE=Release
make -j$(nproc)
echo "[NATIVE] Build tamamlandi: $PROJECT_ROOT/build-native/wasm_engine"
}
# Asset optimizasyonu
optimize_assets() {
echo "[ASSETS] Sprite optimizasyonu..."
find "$PROJECT_ROOT/assets" -name "*.png" | while read -r img; do
if command -v pngquant &>/dev/null; then
pngquant --force --quality=80-95 --output "$img" "$img"
fi
done
echo "[ASSETS] Optimizasyon tamamlandi"
}
case "$BUILD_TYPE" in
wasm) optimize_assets && build_wasm ;;
native) build_native ;;
all) optimize_assets && build_native && build_wasm ;;
*) echo "Gecersiz hedef: $BUILD_TYPE"; exit 1 ;;
esac
echo "=== Build Basarili ==="
HTML Shell ve JavaScript Entegrasyonu
WASM modülünü tarayıcıda ayağa kaldırmak için HTML shell gerekiyor. Performans metrikleri göstermek de işimize yarayacak.
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WASM 2D Engine</title>
<style>
body {
margin: 0;
background: #141420;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: monospace;
color: #ccc;
}
#canvas {
border: 2px solid #333;
display: block;
}
#hud {
padding: 8px 16px;
background: rgba(0,0,0,0.7);
border-radius: 4px;
margin-top: 8px;
font-size: 12px;
}
#loading {
color: #7af;
font-size: 18px;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="hud">FPS: <span id="fps">0</span> | Draw Calls: <span id="drawcalls">0</span> | Memory: <span id="memory">0</span> MB</div>
<p id="loading">Engine yukleniyor...</p>
<script>
// Emscripten Module konfigurasyonu
var Module = {
canvas: (function() {
var canvas = document.getElementById('canvas');
canvas.addEventListener("webglcontextlost", function(e) {
alert('WebGL context kayboldu!');
e.preventDefault();
}, false);
return canvas;
})(),
onRuntimeInitialized: function() {
document.getElementById('loading').style.display = 'none';
console.log('WASM Engine hazir!');
// C++ tarafindaki fonksiyona JS'den erisim
const getEngineStats = Module.cwrap(
'get_engine_stats', 'string', []
);
// HUD guncelleme
setInterval(function() {
try {
const stats = JSON.parse(getEngineStats());
document.getElementById('fps').textContent = stats.fps;
document.getElementById('drawcalls').textContent = stats.drawCalls;
// WASM bellek kullanimi
const memMB = (Module.HEAP8.byteLength / 1024 / 1024).toFixed(1);
document.getElementById('memory').textContent = memMB;
} catch(e) {}
}, 500);
},
print: function(text) { console.log('[ENGINE]', text); },
printErr: function(text) { console.error('[ENGINE ERROR]', text); },
// WASM dosyasi yuklenirken progress goster
setStatus: function(text) {
if (text) document.getElementById('loading').textContent = text;
},
monitorRunDependencies: function(left) {
if (left == 0) {
document.getElementById('loading').textContent =
'Hazir! Baslatiyor...';
}
}
};
// Klavye olaylarini WASM'a ilet
document.addEventListener('keydown', function(e) {
if (Module._handle_keydown) {
Module._handle_keydown(e.keyCode);
}
// Ok tuslari icin scroll'u engelle
if ([32, 37, 38, 39, 40].includes(e.keyCode)) {
e.preventDefault();
}
});
document.addEventListener('keyup', function(e) {
if (Module._handle_keyup) {
Module._handle_keyup(e.keyCode);
}
});
</script>
<script src="wasm_engine.js"></script>
</body>
</html>
Nginx ile Production Deployment
WASM dosyalarının doğru MIME type ve header’larla servis edilmesi gerekiyor. Aksi halde streaming compilation çalışmaz.
# /etc/nginx/sites-available/wasm-game.conf
server {
listen 80;
listen [::]:80;
server_name oyun.example.com;
# HTTPS'e yonlendir
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name oyun.example.com;
root /var/www/wasm-game/web;
index index.html;
# SSL sertifikasi
ssl_certificate /etc/letsencrypt/live/oyun.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/oyun.example.com/privkey.pem;
# WASM icin kritik header'lar
# SharedArrayBuffer icin COOP/COEP gerekli
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "same-site" always;
# Gzip WASM icin
gzip on;
gzip_types application/wasm application/javascript;
gzip_min_length 1024;
# WASM dosyalari icin ozel ayarlar
location ~* .wasm$ {
add_header Content-Type "application/wasm";
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
# Pre-compressed varsa kullan
gzip_static on;
# Uzun sure cache - hash ile versiyonlama yapiliyorsa
expires 30d;
add_header Cache-Control "public, immutable";
}
# Asset dosyalari
location /assets/ {
expires 7d;
add_header Cache-Control "public";
}
# JS dosyalari - versiyonlama yoksa kisa cache
location ~* .js$ {
expires 1h;
add_header Cache-Control "public";
}
location / {
try_files $uri $uri/ =404;
}
# Loglama
access_log /var/log/nginx/wasm-game-access.log;
error_log /var/log/nginx/wasm-game-error.log warn;
}
Performans Profiling ve Izleme
Production’da oyunun nasıl davrandığını izlemek için basit bir profiler entegrasyonu:
#!/bin/bash
# monitor-wasm.sh - WASM oyun performans izleme
NGINX_LOG="/var/log/nginx/wasm-game-access.log"
REPORT_FILE="/tmp/wasm-perf-report-$(date +%Y%m%d-%H%M).txt"
echo "=== WASM Game Performans Raporu ===" > "$REPORT_FILE"
echo "Tarih: $(date)" >> "$REPORT_FILE"
echo "" >> "$REPORT_FILE"
# WASM dosya indirme sureleri
echo "--- WASM Dosya Yüklenme Süreleri ---" >> "$REPORT_FILE"
grep ".wasm" "$NGINX_LOG" | awk '{
# Request time son alan
split($NF, t, ".");
total += $NF;
count++;
if ($NF > max) max = $NF;
}
END {
if (count > 0) {
printf "Toplam istek: %dn", count;
printf "Ortalama süre: %.3f snn", total/count;
printf "Maksimum süre: %.3f snn", max;
}
}' >> "$REPORT_FILE"
# Bant genisligi kullanimi
echo "" >> "$REPORT_FILE"
echo "--- Bant Genisligi (Son 1 saat) ---" >> "$REPORT_FILE"
awk -v cutoff="$(date -d '1 hour ago' '+%d/%b/%Y:%H:%M:%S')" '
$4 > "["cutoff {total += $10}
END {printf "Toplam: %.2f MBn", total/1024/1024}
' "$NGINX_LOG" >> "$REPORT_FILE"
# En cok yuklenen asset'ler
echo "" >> "$REPORT_FILE"
echo "--- En Cok Istenen Kaynaklar ---" >> "$REPORT_FILE"
grep -o '"GET [^"]*"' "$NGINX_LOG" | sort | uniq -c | sort -rn | head -10 >> "$REPORT_FILE"
cat "$REPORT_FILE"
echo ""
echo "Rapor kaydedildi: $REPORT_FILE"
Gerçek Dünya Deneyimlerinden Notlar
Birkaç projede bu pipeline’ı kullandım ve karşılaştığım sorunları paylaşayım.
Memory sızıntısı: C++ tarafında SDL_FreeSurface çağrısını unuttuğumda, WASM heap hızla doluyordu. ALLOW_MEMORY_GROWTH=1 kurtarıcı ama sonsuz değil. Chrome DevTools’un Memory sekmesi WASM heap’i de gösteriyor, bunu kullanın.
CORS sorunları: SharedArrayBuffer kullanmak istiyorsanız mutlaka COOP ve COEP header’larını ayarlayın. Bu olmadan multi-thread WASM çalışmıyor. Firebase Hosting veya Netlify’da bu header’ları eklemek için platform bazlı konfigürasyon dosyası gerekiyor.
Asset yükleme sırası: Emscripten’in preload sistemi iyi çalışıyor ama büyük asset paketlerinde ilk yüklenme uzuyor. Bu yüzden asset’leri kategorilere bölün, önce kritik olanları yükleyin.
Mobile tarayıcılar: iOS Safari’de WebAssembly desteği var ama bazı eski versiyonlarda SharedArrayBuffer yok. Graceful degradation için feature detection yapın.
- Dosya boyutu: WASM binary’si başlangıçta büyük görünebilir.
-O3 -fltoflag’lerini kullanın ve--closure 1ile JS tarafını da minify edin - Debug modu:
ASSERTIONS=2ve-g4flag’leriyle source map üretebilirsiniz, gerçekten çok işe yarıyor - Async loading:
emscripten_async_wgetkullanarak level asset’lerini lazy load edebilirsiniz
Sonuç
WebAssembly ile 2D oyun motoru geliştirmek başta korkutucu görünüyor ama aslında sysadmin bakış açısıyla değerlendirdiğinizde oldukça sistematik bir süreç. C++ kod tabanınızı koruyorsunuz, Emscripten toolchain derlemeyi hallediyor, Nginx ise doğru header’larla servis ediyor.
Bu yaklaşımın en güçlü yanı platform bağımsızlığı. Aynı oyun motoru hem Windows/Linux masaüstünde hem de Chrome’da çalışıyor. Steam’e yayınlayıp aynı anda bir web demosu sunmak artık çok büyük bir overhead gerektirmiyor.
Performans tarafında ise JavaScript tabanlı çözümlere kıyasla özellikle fizik hesaplamalarında ve büyük sprite sayılarında ciddi fark görüyorsunuz. 60 FPS’i korumak için game loop yapısına ve draw call optimizasyonuna dikkat etmek yeterli.
Sonraki adım olarak audio sistemi, WebSocket ile multiplayer desteği veya WebGPU entegrasyonunu deneyebilirsiniz. WebGPU özellikle 2024 sonu itibarıyla browser desteği genişlediğinden, shader tabanlı efektler için ciddi bir seçenek haline geliyor.
