Press R to restart the game.Oyunu yeniden başlatmak için R tuşuna basın.
Practicing the Graphics Pipeline With a Small WebGL Prototype
This project was a small practice exercise I built while learning the basics of the computer graphics pipeline. I used a simple fish survival scene as the visual context, but the main purpose was to understand how geometry data, buffers, transformations, shaders, and the render loop connect to produce something visible on screen.
The result is a simple interactive scene, but the important part for me was not the game design. It was working through the rendering flow more directly than I would inside a game engine.
Starting With Geometry
The first part of the project was defining the fish shape manually. Instead of importing a model, I created the fish from vertex positions in code.
this.vertices = [
vec3(-0.05, 0.15, 0.07),
vec3(-0.05, 0.15, -0.07),
vec3(0.15, 0.15, 0.07),
vec3(0.15, 0.15, -0.07),
vec3(-0.05, -0.15, 0.07),
vec3(-0.05, -0.15, -0.07),
vec3(0.15, -0.15, 0.07),
vec3(0.15, -0.15, -0.07),
// ...
];
This made the mesh feel more concrete to me. The object on screen was not just an asset. It was a list of points that would later be connected into triangles.
I also defined index data to tell WebGL how those vertices should form triangle faces.
this.indices = [
0, 1, 2,
1, 2, 3,
2, 3, 8,
3, 8, 9,
// ...
];
This was useful for understanding the difference between vertex data and indexed drawing. The vertices describe positions, while the indices describe how those positions are reused to build the final mesh.
Adding Vertex Colors
Alongside the positions, I also gave the fish color data.
this.colors = [
vec4(0.0, 0.0, 0.6, 1.0),
vec4(0.0, 0.0, 0.6, 1.0),
vec4(0.0, 0.0, 0.8, 1.0),
vec4(0.0, 0.0, 0.8, 1.0),
// ...
];
This helped me understand how different vertex attributes can travel through the pipeline together. The mesh was not only position data; each vertex could also carry color information that would affect the final fragment output.
Sending Data to WebGL Buffers
After defining geometry, the next step was sending that data to WebGL. I created separate buffers for indices, colors, and positions.
var iBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, iBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint8Array(this.indices), gl.STATIC_DRAW);
The index buffer stores the order in which vertices are used to draw triangles.
var cBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, cBuffer);
gl.bufferData(gl.ARRAY_BUFFER, flatten(this.colors), gl.STATIC_DRAW);
fishVColor = gl.getAttribLocation(fishProgram, "vColor");
gl.vertexAttribPointer(fishVColor, 4, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(fishVColor);
The color buffer is connected to the vColor attribute in the shader program.
var vBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vBuffer);
gl.bufferData(gl.ARRAY_BUFFER, flatten(this.vertices), gl.STATIC_DRAW);
var vPosition = gl.getAttribLocation(fishProgram, "vPosition");
gl.vertexAttribPointer(vPosition, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(vPosition);
The position buffer is connected to the vPosition attribute. This part was one of the most important learning points of the project because it showed the bridge between JavaScript-side data and GPU-side shader input.
Transforming Objects
To move and scale the fish, I used transformation matrices. Each fish had translation, rotation, and scale values.
this.translation = translate(this.location.clipCoordinates);
this.rotation = rotate(0, 0, 0, 1);
this.scale = scalem(this.size, this.size, this.size);
Then I combined them into a single matrix before drawing.
computeMatrix() {
var m = mat4();
m = mult(m, this.translation);
m = mult(m, this.rotation);
m = mult(m, this.scale);
return m;
}
This helped me practice one of the core ideas of the graphics pipeline: objects usually start in their own local space, then transformations place them into the scene.
In this project, the fish positions were also converted into clip-space-style coordinates.
this.clipCoordinates = [
this.coordinates[0] / this.maxLocation,
this.coordinates[1] / this.maxLocation,
this.coordinates[2] / this.maxLocation
];
That made the relationship between world-like coordinates and screen rendering easier to see.
Passing the Matrix to the Shader
Before drawing each object, I passed the transformation matrix to the shader as a uniform.
var uMatrixLoc = gl.getUniformLocation(fishProgram, "uMatrix");
gl.uniformMatrix4fv(uMatrixLoc, false, flatten(this.computeMatrix()));
This was another important pipeline step. The CPU side prepares object data and transformation values, then the shader uses those values to place vertices correctly.
After that, the fish is drawn with indexed triangles.
gl.drawElements(gl.TRIANGLES, this.numVertices, gl.UNSIGNED_BYTE, 0);
This made the final rendering step much clearer to me: after buffers, attributes, uniforms, and shader programs are prepared, drawElements is the command that actually asks WebGL to draw the geometry.
The Render Loop
The project also includes a basic real-time render loop.
function render() {
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(57 / 255, 174 / 255, 179 / 255, 1.0);
gl.enable(gl.DEPTH_TEST);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
player.update();
player.display();
for (i = fishes.length - 1; i >= 0; i--) {
fishes[i].update();
fishes[i].display();
}
time = Date.now();
window.requestAnimationFrame(render);
}
This connected the graphics side with the real-time side. Each frame clears the screen, updates object states, sends current transformation data, and draws the scene again.
Using requestAnimationFrame also made the frame-by-frame structure of real-time rendering more obvious. The image on screen is rebuilt continuously from updated object data.
Input and Visual Updates
The player fish follows the mouse position. Mouse coordinates are converted into normalized values and used as the target position.
canvas.addEventListener('mousemove', e => {
var pos = getRelativeMousePosition(e, canvas);
const x = pos.x / gl.canvas.width * 2 - 1;
const y = pos.y / gl.canvas.height * -2 + 1;
player.setTargetLocation(x, y, player.location.clipCoordinates[2]);
});
This was useful because it connected input handling to rendering. The mouse changes the target, the player updates its position, the transformation matrix changes, and the new matrix affects where the fish appears in the next frame.
What I Practiced
This project helped me practice several parts of the graphics pipeline in a small example:
- Defining geometry manually with vertices
- Using indices to form triangle meshes
- Assigning per-vertex color data
- Creating WebGL buffers
- Connecting buffers to shader attributes
- Combining translation, rotation, and scale matrices
- Passing uniforms to shaders
- Drawing with
gl.drawElements - Clearing and redrawing the scene every frame
- Connecting input to rendered object movement
Final Thoughts
The project itself is simple, but it was useful because it made the graphics pipeline more concrete. I could see how vertex data, color data, buffers, shader attributes, uniforms, matrices, and draw calls work together.
Instead of thinking about rendering as something hidden inside an engine, this project gave me a closer look at how a visual scene is assembled step by step and updated in real time.
Küçük Bir WebGL Prototipiyle Grafik İşlem Hattını Öğrenmek
Bu proje, bilgisayar grafiklerindeki temel işlem hattını öğrenirken geliştirdiğim küçük bir alıştırmaydı. Görsel bağlam olarak basit bir balık hayatta kalma sahnesi kullandım ancak projenin asıl amacı oyun tasarımı değildi. Geometri verilerinin, buffer yapılarının, dönüşümlerin, shader’ların ve render döngüsünün ekranda görünen bir sonuç üretmek için nasıl birlikte çalıştığını anlamaktı.
Ortaya çıkan sonuç basit ve etkileşimli bir sahneydi. Benim için önemli olan ise bir oyun motorunda büyük ölçüde gizlenen render sürecini daha doğrudan incelemekti.
Geometri
Projenin ilk aşamasında balığın geometrisini elle tanımladım. Hazır bir model kullanmak yerine balığı, kod içinde belirlediğim köşe konumlarından oluşturdum.
this.vertices = [
vec3(-0.05, 0.15, 0.07),
vec3(-0.05, 0.15, -0.07),
vec3(0.15, 0.15, 0.07),
vec3(0.15, 0.15, -0.07),
vec3(-0.05, -0.15, 0.07),
vec3(-0.05, -0.15, -0.07),
vec3(0.15, -0.15, 0.07),
vec3(0.15, -0.15, -0.07),
// ...
];
Bu yaklaşım mesh yapısını benim için daha somut hâle getirdi. Ekrandaki nesne yalnızca hazır bir varlık değil, daha sonra üçgenler oluşturacak şekilde birbirine bağlanan noktalardan oluşuyordu.
WebGL’e bu köşelerin hangi sırayla üçgen yüzeyler oluşturacağını belirtmek için indeks verileri de tanımladım.
this.indices = [
0, 1, 2,
1, 2, 3,
2, 3, 8,
3, 8, 9,
// ...
];
Bu yapı, köşe verileri ile indeksli çizim arasındaki farkı anlamam açısından faydalı oldu. Köşeler konumları tanımlarken indeksler, bu konumların nihai mesh’i oluşturmak için nasıl yeniden kullanılacağını belirliyordu.
Köşe Renkleri Eklemek
Konum verilerinin yanında balığın renklerini de tanımladım.
this.colors = [
vec4(0.0, 0.0, 0.6, 1.0),
vec4(0.0, 0.0, 0.6, 1.0),
vec4(0.0, 0.0, 0.8, 1.0),
vec4(0.0, 0.0, 0.8, 1.0),
// ...
];
Bu aşama, farklı köşe özniteliklerinin grafik işlem hattında birlikte nasıl taşındığını anlamama yardımcı oldu. Mesh yalnızca konum verilerinden oluşmuyordu; her köşe, nihai fragment çıktısını etkileyen renk bilgisini de taşıyabiliyordu.
Verileri WebGL Buffer’larına Göndermek
Geometriyi tanımladıktan sonraki adım, bu verileri WebGL’e göndermekti. İndeksler, renkler ve konumlar için ayrı buffer’lar oluşturdum.
var iBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, iBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint8Array(this.indices), gl.STATIC_DRAW);
İndeks buffer’ı, üçgenlerin çizilmesi sırasında köşelerin hangi sırayla kullanılacağını saklıyordu.
var cBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, cBuffer);
gl.bufferData(gl.ARRAY_BUFFER, flatten(this.colors), gl.STATIC_DRAW);
fishVColor = gl.getAttribLocation(fishProgram, "vColor");
gl.vertexAttribPointer(fishVColor, 4, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(fishVColor);
Renk buffer’ı, shader programındaki vColor özniteliğine bağlanıyordu.
var vBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vBuffer);
gl.bufferData(gl.ARRAY_BUFFER, flatten(this.vertices), gl.STATIC_DRAW);
var vPosition = gl.getAttribLocation(fishProgram, "vPosition");
gl.vertexAttribPointer(vPosition, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(vPosition);
Konum buffer’ı ise vPosition özniteliğine bağlanıyordu. Projenin en önemli öğrenme noktalarından biri bu bölümdü çünkü JavaScript tarafındaki veriler ile GPU tarafındaki shader girdileri arasındaki bağlantıyı açık biçimde gösteriyordu.
Nesnelere Dönüşüm Uygulamak
Balıkları hareket ettirmek ve ölçeklendirmek için dönüşüm matrisleri kullandım. Her balığın öteleme, döndürme ve ölçek değerleri bulunuyordu.
this.translation = translate(this.location.clipCoordinates);
this.rotation = rotate(0, 0, 0, 1);
this.scale = scalem(this.size, this.size, this.size);
Çizimden önce bu matrisleri tek bir dönüşüm matrisi içinde birleştirdim.
computeMatrix() {
var m = mat4();
m = mult(m, this.translation);
m = mult(m, this.rotation);
m = mult(m, this.scale);
return m;
}
Bu süreç, grafik işlem hattının temel fikirlerinden birini uygulamamı sağladı. Nesneler önce kendi yerel koordinat sistemlerinde tanımlanıyor, ardından dönüşümler aracılığıyla sahne içindeki konumlarına yerleştiriliyordu.
Projede balıkların konumlarını clip space benzeri koordinatlara da dönüştürdüm.
this.clipCoordinates = [
this.coordinates[0] / this.maxLocation,
this.coordinates[1] / this.maxLocation,
this.coordinates[2] / this.maxLocation
];
Bu işlem, dünya benzeri koordinatlarla ekran üzerindeki çizim arasındaki ilişkiyi daha açık şekilde görmemi sağladı.
Matrisi Shader’a Göndermek
Her nesneyi çizmeden önce dönüşüm matrisini shader’a bir uniform değişken olarak gönderdim.
var uMatrixLoc = gl.getUniformLocation(fishProgram, "uMatrix");
gl.uniformMatrix4fv(uMatrixLoc, false, flatten(this.computeMatrix()));
Bu da grafik işlem hattındaki bir diğer önemli adımdı. CPU tarafı nesne verilerini ve dönüşüm değerlerini hazırlıyor, shader ise bu değerleri kullanarak köşeleri doğru konumlara yerleştiriyordu.
Ardından balık, indeksli üçgenler kullanılarak çiziliyordu.
gl.drawElements(gl.TRIANGLES, this.numVertices, gl.UNSIGNED_BYTE, 0);
Bu aşama nihai çizim sürecini benim için daha anlaşılır hâle getirdi. Buffer’lar, öznitelikler, uniform’lar ve shader programları hazırlandıktan sonra drawElements, WebGL’e geometriyi çizmesini söyleyen komuttu.
Render Döngüsü
Projede temel bir gerçek zamanlı render döngüsü de bulunuyordu.
function render() {
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(57 / 255, 174 / 255, 179 / 255, 1.0);
gl.enable(gl.DEPTH_TEST);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
player.update();
player.display();
for (i = fishes.length - 1; i >= 0; i--) {
fishes[i].update();
fishes[i].display();
}
time = Date.now();
window.requestAnimationFrame(render);
}
Bu yapı, grafik tarafıyla gerçek zamanlı güncelleme mantığını bir araya getiriyordu. Her karede ekran temizleniyor, nesnelerin durumları güncelleniyor, güncel dönüşüm verileri gönderiliyor ve sahne yeniden çiziliyordu.
requestAnimationFrame kullanımı, gerçek zamanlı çizimin kare kare çalışma yapısını da daha açık hâle getirdi. Ekrandaki görüntü, güncellenen nesne verilerinden sürekli olarak yeniden oluşturuluyordu.
Girdi ve Görsel Güncellemeler
Oyuncunun kontrol ettiği balık, fare konumunu takip ediyordu. Fare koordinatları normalize edilerek hedef konum olarak kullanılıyordu.
canvas.addEventListener('mousemove', e => {
var pos = getRelativeMousePosition(e, canvas);
const x = pos.x / gl.canvas.width * 2 - 1;
const y = pos.y / gl.canvas.height * -2 + 1;
player.setTargetLocation(x, y, player.location.clipCoordinates[2]);
});
Bu bölüm, kullanıcı girdisi ile render süreci arasındaki bağlantıyı görmem açısından faydalıydı. Fare hedefi değiştiriyor, oyuncunun konumu güncelleniyor, dönüşüm matrisi yeniden hesaplanıyor ve yeni matris balığın bir sonraki karede ekranda göründüğü konumu belirliyordu.
Uyguladığım Konular
Bu proje, grafik işlem hattının farklı aşamalarını küçük bir örnek üzerinde uygulamamı sağladı:
- Köşe noktalarıyla geometriyi elle tanımlamak
- İndeksler kullanarak üçgen mesh’ler oluşturmak
- Köşe başına renk verisi atamak
- WebGL buffer’ları oluşturmak
- Buffer’ları shader özniteliklerine bağlamak
- Öteleme, döndürme ve ölçekleme matrislerini birleştirmek
- Uniform değerleri shader’a göndermek
gl.drawElementsile çizim yapmak- Sahneyi her karede temizleyip yeniden çizmek
- Kullanıcı girdisini nesne hareketine bağlamak
Son Düşünceler
Projenin kendisi basit olsa da grafik işlem hattını daha somut hâle getirdiği için faydalı bir çalışma oldu. Köşe verilerinin, renklerin, buffer’ların, shader özniteliklerinin, uniform’ların, matrislerin ve çizim çağrılarının birlikte nasıl çalıştığını doğrudan görebildim.
Bir oyun motorunda büyük ölçüde arka planda yürütülen render sürecini kullanmak yerine, görsel bir sahnenin adım adım nasıl oluşturulduğunu ve gerçek zamanlı olarak nasıl güncellendiğini daha yakından inceleme fırsatı buldum.