quake-rb 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "opengl"
4
+ require_relative "gl_alias_model"
4
5
 
5
6
  module Quake
6
7
  module Renderer
7
8
  # Simple particle system for visual effects.
8
- # Particles are rendered as GL_POINTS with depth test enabled.
9
- # Matches Quake's R_DrawParticles (gl_rpart.c).
9
+ # Matches Quake's GL R_DrawParticles (r_part.c).
10
10
  class GLParticles
11
11
  GRAVITY = 800.0
12
12
  MAX_PARTICLES = 2048
13
+ PARTICLE_TEXTURE_SIZE = 8
14
+ PARTICLE_BILLBOARD_SIZE = 1.5
15
+ PARTICLE_SCALE_DISTANCE = 20.0
16
+ PARTICLE_SCALE_FACTOR = 0.004
17
+
18
+ DOT_TEXTURE = [
19
+ [0, 1, 1, 0, 0, 0, 0, 0],
20
+ [1, 1, 1, 1, 0, 0, 0, 0],
21
+ [1, 1, 1, 1, 0, 0, 0, 0],
22
+ [0, 1, 1, 0, 0, 0, 0, 0],
23
+ [0, 0, 0, 0, 0, 0, 0, 0],
24
+ [0, 0, 0, 0, 0, 0, 0, 0],
25
+ [0, 0, 0, 0, 0, 0, 0, 0],
26
+ [0, 0, 0, 0, 0, 0, 0, 0]
27
+ ].freeze
13
28
 
14
29
  # Quake ramp tables for color animation (palette indices)
15
30
  RAMP1 = [0x6f, 0x6d, 0x6b, 0x69, 0x67, 0x65, 0x63, 0x61].freeze
16
31
  RAMP2 = [0x6f, 0x6e, 0x6d, 0x6c, 0x6b, 0x6a, 0x68, 0x66].freeze
17
- RAMP3 = [0x6d, 0x6b, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01].freeze # explosion
32
+ RAMP3 = [0x6d, 0x6b, 0x06, 0x05, 0x04, 0x03, 0x00, 0x00].freeze # explosion
18
33
 
19
34
  Particle = Struct.new(:x, :y, :z, :vx, :vy, :vz,
20
35
  :r, :g, :b, :a,
@@ -35,20 +50,12 @@ module Quake
35
50
  p.y += p.vy * dt
36
51
  p.z += p.vz * dt
37
52
 
38
- # Apply gravity
39
- p.vz -= GRAVITY * p.gravity_scale * dt
40
-
41
53
  # Color ramp animation
42
- if p.ramp_type
43
- p.ramp += dt * 10.0
54
+ if p.ramp_type && (ramp = ramp_for_type(p.ramp_type))
55
+ p.ramp += dt * ramp_rate(p.ramp_type)
44
56
  idx = p.ramp.to_i
45
- ramp = case p.ramp_type
46
- when :explosion then RAMP3
47
- when :fire then RAMP1
48
- else RAMP2
49
- end
50
57
 
51
- if idx >= ramp.size
58
+ if idx >= ramp_expiry_size(p.ramp_type, ramp)
52
59
  next true # particle expired
53
60
  end
54
61
 
@@ -58,51 +65,163 @@ module Quake
58
65
  p.b = color[2] / 255.0
59
66
  end
60
67
 
61
- # Fade alpha
62
- p.a = (p.life * 2.0).clamp(0.0, 1.0)
68
+ update_velocity(p, dt)
63
69
 
64
70
  false
65
71
  end
66
72
  end
67
73
 
68
- def render
74
+ def render(camera: nil)
69
75
  return if @particles.empty?
70
76
 
71
- GL.Disable(GL::TEXTURE_2D)
77
+ upload_particle_texture unless @particle_texture
78
+ view_origin = camera&.position || Math::Vec3::ORIGIN
79
+ view_forward = camera&.forward || Math::Vec3.new(1.0, 0.0, 0.0)
80
+ up = (camera&.up || Math::Vec3.new(0.0, 0.0, 1.0)) * PARTICLE_BILLBOARD_SIZE
81
+ right = (camera&.right || Math::Vec3.new(0.0, -1.0, 0.0)) * PARTICLE_BILLBOARD_SIZE
82
+
83
+ GL.Enable(GL::TEXTURE_2D)
84
+ GL.BindTexture(GL::TEXTURE_2D, @particle_texture)
85
+ GL.Disable(GL::ALPHA_TEST)
72
86
  GL.Enable(GL::BLEND)
87
+ GL.TexEnvi(GL::TEXTURE_ENV, GL::TEXTURE_ENV_MODE, GL::MODULATE)
73
88
  GL.BlendFunc(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA)
74
- GL.PointSize(2.0)
89
+ GL.DepthMask(GL::FALSE)
75
90
 
76
- GL.Begin(GL::POINTS)
91
+ GL.Begin(GL::TRIANGLES)
77
92
  @particles.each do |p|
78
- GL.Color4f(p.r, p.g, p.b, p.a)
93
+ scale = particle_scale(p, view_origin, view_forward)
94
+ GL.Color3f(p.r, p.g, p.b)
95
+ GL.TexCoord2f(0.0, 0.0)
79
96
  GL.Vertex3f(p.x, p.y, p.z)
97
+ GL.TexCoord2f(1.0, 0.0)
98
+ GL.Vertex3f(p.x + up.x * scale, p.y + up.y * scale, p.z + up.z * scale)
99
+ GL.TexCoord2f(0.0, 1.0)
100
+ GL.Vertex3f(p.x + right.x * scale, p.y + right.y * scale, p.z + right.z * scale)
80
101
  end
81
102
  GL.End
82
103
 
83
- GL.PointSize(1.0)
104
+ GL.DepthMask(GL::TRUE)
84
105
  GL.Disable(GL::BLEND)
85
- GL.Color4f(1.0, 1.0, 1.0, 1.0)
106
+ GL.TexEnvi(GL::TEXTURE_ENV, GL::TEXTURE_ENV_MODE, GL::REPLACE)
107
+ GL.Color3f(1.0, 1.0, 1.0)
86
108
  end
87
109
 
88
- # Teleporter sparkle effect at a position
89
- def teleport_splash(pos)
90
- 80.times do
91
- dx = (rand * 64.0) - 32.0
92
- dy = (rand * 64.0) - 32.0
93
- dz = (rand * 64.0) - 32.0
94
- color_idx = 7 + (rand * 8).to_i
95
- color = @palette.rgb(color_idx)
110
+ # Quake R_EntityParticles: EF_BRIGHTFIELD particles orbit each alias normal.
111
+ def entity_particles(pos, time:)
112
+ entity_particle_avelocities.each_with_index do |velocities, index|
113
+ yaw = time.to_f * velocities[0]
114
+ pitch = time.to_f * velocities[1]
115
+ roll = time.to_f * velocities[2]
116
+ sy = ::Math.sin(yaw)
117
+ cy = ::Math.cos(yaw)
118
+ sp = ::Math.sin(pitch)
119
+ cp = ::Math.cos(pitch)
120
+ sr = ::Math.sin(roll)
121
+ cr = ::Math.cos(roll)
122
+ forward = [cp * cy, cp * sy, -sp]
123
+ normal = GLAliasModel::ANORMS[index]
124
+ color = @palette.rgb(0x6f)
125
+
96
126
  emit(
97
- x: pos.x + dx, y: pos.y + dy, z: pos.z + dz,
98
- vx: dx * 2.0, vy: dy * 2.0, vz: dz * 2.0 + 80.0,
127
+ x: pos.x + normal[0] * 64.0 + forward[0] * 16.0,
128
+ y: pos.y + normal[1] * 64.0 + forward[1] * 16.0,
129
+ z: pos.z + normal[2] * 64.0 + forward[2] * 16.0,
130
+ vx: 0.0, vy: 0.0, vz: 0.0,
99
131
  r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
100
- life: 0.5 + rand * 0.3,
101
- gravity_scale: 0.2
132
+ life: 0.01,
133
+ ramp_type: :explosion
102
134
  )
103
135
  end
104
136
  end
105
137
 
138
+ # Quake R_RocketTrail: projectile, blood, tracer, and voor trails.
139
+ def rocket_trail(start_pos, end_pos, type:)
140
+ dir_x = end_pos.x - start_pos.x
141
+ dir_y = end_pos.y - start_pos.y
142
+ dir_z = end_pos.z - start_pos.z
143
+ len = ::Math.sqrt(dir_x * dir_x + dir_y * dir_y + dir_z * dir_z)
144
+ return if len.zero?
145
+
146
+ dir_x /= len
147
+ dir_y /= len
148
+ dir_z /= len
149
+
150
+ dec = 3.0
151
+ if type >= 128
152
+ dec = 1.0
153
+ type -= 128
154
+ end
155
+
156
+ x = start_pos.x
157
+ y = start_pos.y
158
+ z = start_pos.z
159
+ while len.positive?
160
+ len -= dec
161
+ emit_rocket_trail_particle(x, y, z, dir_x, dir_y, dir_z, type)
162
+ len -= 3.0 if type == 4
163
+ x += dir_x
164
+ y += dir_y
165
+ z += dir_z
166
+ end
167
+ end
168
+
169
+ # Teleporter sparkle effect at a position
170
+ def teleport_splash(pos)
171
+ (-16...16).step(4) do |i|
172
+ (-16...16).step(4) do |j|
173
+ (-24...32).step(4) do |k|
174
+ color_idx = 7 + (rand * 8).to_i
175
+ color = @palette.rgb(color_idx)
176
+ dir_x = j * 8.0
177
+ dir_y = i * 8.0
178
+ dir_z = k * 8.0
179
+ length = ::Math.sqrt(dir_x * dir_x + dir_y * dir_y + dir_z * dir_z)
180
+ vel = 50.0 + (rand * 64).to_i
181
+ scale = length.zero? ? 0.0 : vel / length
182
+ emit(
183
+ x: pos.x + i + (rand * 4).to_i,
184
+ y: pos.y + j + (rand * 4).to_i,
185
+ z: pos.z + k + (rand * 4).to_i,
186
+ vx: dir_x * scale,
187
+ vy: dir_y * scale,
188
+ vz: dir_z * scale,
189
+ r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
190
+ life: 0.2 + (rand * 8).to_i * 0.02,
191
+ gravity_scale: 1.0
192
+ )
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ # Quake temp entity TE_LAVASPLASH.
199
+ def lava_splash(pos)
200
+ (-16...16).each do |i|
201
+ (-16...16).each do |j|
202
+ color_idx = 224 + (rand * 8).to_i
203
+ color = @palette.rgb(color_idx)
204
+ dir_x = j * 8.0 + (rand * 8).to_i
205
+ dir_y = i * 8.0 + (rand * 8).to_i
206
+ dir_z = 256.0
207
+ length = ::Math.sqrt(dir_x * dir_x + dir_y * dir_y + dir_z * dir_z)
208
+ vel = 50.0 + (rand * 64).to_i
209
+ scale = vel / length
210
+ emit(
211
+ x: pos.x + dir_x,
212
+ y: pos.y + dir_y,
213
+ z: pos.z + (rand * 64).to_i,
214
+ vx: dir_x * scale,
215
+ vy: dir_y * scale,
216
+ vz: dir_z * scale,
217
+ r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
218
+ life: 2.0 + (rand * 32).to_i * 0.02,
219
+ gravity_scale: 1.0
220
+ )
221
+ end
222
+ end
223
+ end
224
+
106
225
  # Item pickup sparkle
107
226
  def pickup_effect(pos)
108
227
  20.times do
@@ -124,48 +243,269 @@ module Quake
124
243
 
125
244
  # Explosion burst
126
245
  def explosion(pos)
127
- 128.times do
128
- color = @palette.rgb(0x6d)
246
+ 1024.times do |i|
247
+ emit_explosion_particle(pos, index: i)
248
+ end
249
+ end
250
+
251
+ # Quake temp entity TE_EXPLOSION2: color-mapped particle explosion.
252
+ def color_mapped_explosion(pos, color_start:, color_length:)
253
+ color_mod = 0
254
+ 512.times do
255
+ color_idx = color_start + (color_mod % color_length)
256
+ color_mod += 1
257
+ color = @palette.rgb(color_idx)
129
258
  emit(
130
- x: pos.x + rand * 16 - 8, y: pos.y + rand * 16 - 8, z: pos.z + rand * 16 - 8,
131
- vx: (rand * 512) - 256, vy: (rand * 512) - 256, vz: (rand * 512) - 256,
259
+ x: pos.x + (rand * 32).to_i - 16,
260
+ y: pos.y + (rand * 32).to_i - 16,
261
+ z: pos.z + (rand * 32).to_i - 16,
262
+ vx: (rand * 512).to_i - 256,
263
+ vy: (rand * 512).to_i - 256,
264
+ vz: (rand * 512).to_i - 256,
132
265
  r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
133
- life: 0.5 + rand * 0.5,
134
- gravity_scale: 0.05,
135
- ramp_type: :explosion
266
+ life: 0.3,
267
+ ramp_type: :blob
136
268
  )
137
269
  end
138
270
  end
139
271
 
140
- # Blood spurt
272
+ # Quake temp entity TE_TAREXPLOSION: tarbaby blob explosion.
273
+ def blob_explosion(pos)
274
+ 1024.times do |i|
275
+ color_idx = i.odd? ? 66 + (rand * 6).to_i : 150 + (rand * 6).to_i
276
+ color = @palette.rgb(color_idx)
277
+ emit(
278
+ x: pos.x + (rand * 32).to_i - 16,
279
+ y: pos.y + (rand * 32).to_i - 16,
280
+ z: pos.z + (rand * 32).to_i - 16,
281
+ vx: (rand * 512).to_i - 256,
282
+ vy: (rand * 512).to_i - 256,
283
+ vz: (rand * 512).to_i - 256,
284
+ r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
285
+ life: 1.0 + (((rand * 256).to_i & 8) * 0.05),
286
+ ramp_type: i.odd? ? :blob : :blob2
287
+ )
288
+ end
289
+ end
290
+
291
+ # QuakeWorld TE_BLOOD uses R_RunParticleEffect with blood palette color 73.
141
292
  def blood(pos, count: 20)
293
+ run_particle_effect(pos, color: 73, count: count)
294
+ end
295
+
296
+ # QuakeWorld TE_LIGHTNINGBLOOD.
297
+ def lightning_blood(pos)
298
+ run_particle_effect(pos, color: 225, count: 50)
299
+ end
300
+
301
+ # Quake R_RunParticleEffect: used by gunshots and spike impact puffs.
302
+ def run_particle_effect(pos, color:, count:, dir: nil)
303
+ if count == 1024
304
+ count.times { |i| emit_explosion_particle(pos, index: i) }
305
+ return
306
+ end
307
+
308
+ color_base = color & ~7
309
+ dir_x = dir ? dir.x : 0.0
310
+ dir_y = dir ? dir.y : 0.0
311
+ dir_z = dir ? dir.z : 0.0
312
+
142
313
  count.times do
143
- color_idx = 67 + (rand * 4).to_i # dark red palette range
144
- color = @palette.rgb(color_idx)
314
+ color = @palette.rgb(color_base + (rand * 8).to_i)
145
315
  emit(
146
- x: pos.x, y: pos.y, z: pos.z,
147
- vx: (rand * 128) - 64, vy: (rand * 128) - 64, vz: (rand * 128) - 64,
316
+ x: pos.x + (rand * 16).to_i - 8,
317
+ y: pos.y + (rand * 16).to_i - 8,
318
+ z: pos.z + (rand * 16).to_i - 8,
319
+ vx: dir_x * 15.0, vy: dir_y * 15.0, vz: dir_z * 15.0,
148
320
  r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
149
- life: 0.5 + rand * 0.3,
321
+ life: (rand * 5).to_i * 0.1,
150
322
  gravity_scale: 1.0
151
323
  )
152
324
  end
153
325
  end
154
326
 
327
+ # Quake temp entity TE_GUNSHOT: a small wall puff at bullet impact.
328
+ def gunshot(pos, count: 20)
329
+ run_particle_effect(pos, color: 0, count: count)
330
+ end
331
+
155
332
  def particle_count
156
333
  @particles.size
157
334
  end
158
335
 
159
336
  private
160
337
 
338
+ def upload_particle_texture
339
+ buf = "\0" * 4
340
+ GL.GenTextures(1, buf)
341
+ @particle_texture = buf.unpack1("V")
342
+
343
+ GL.BindTexture(GL::TEXTURE_2D, @particle_texture)
344
+ GL.TexImage2D(GL::TEXTURE_2D, 0, GL::RGBA,
345
+ PARTICLE_TEXTURE_SIZE, PARTICLE_TEXTURE_SIZE, 0,
346
+ GL::RGBA, GL::UNSIGNED_BYTE, particle_texture_rgba)
347
+ GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MIN_FILTER, GL::LINEAR)
348
+ GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MAG_FILTER, GL::LINEAR)
349
+ end
350
+
351
+ def particle_texture_rgba
352
+ DOT_TEXTURE.flat_map.with_index do |row, y|
353
+ row.flat_map.with_index do |_value, x|
354
+ [255, 255, 255, DOT_TEXTURE[x][y] * 255]
355
+ end
356
+ end.pack("C*")
357
+ end
358
+
359
+ def particle_scale(particle, view_origin, view_forward)
360
+ distance = (particle.x - view_origin.x) * view_forward.x +
361
+ (particle.y - view_origin.y) * view_forward.y +
362
+ (particle.z - view_origin.z) * view_forward.z
363
+ return 1.0 if distance < PARTICLE_SCALE_DISTANCE
364
+
365
+ 1.0 + distance * PARTICLE_SCALE_FACTOR
366
+ end
367
+
368
+ def emit_explosion_particle(pos, index:)
369
+ ramp = (rand * 4).to_i
370
+ color = @palette.rgb(RAMP1[0])
371
+ emit(
372
+ x: pos.x + (rand * 32).to_i - 16,
373
+ y: pos.y + (rand * 32).to_i - 16,
374
+ z: pos.z + (rand * 32).to_i - 16,
375
+ vx: (rand * 512).to_i - 256,
376
+ vy: (rand * 512).to_i - 256,
377
+ vz: (rand * 512).to_i - 256,
378
+ r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
379
+ life: 5.0,
380
+ ramp: ramp,
381
+ ramp_type: index.odd? ? :explosion : :explosion2
382
+ )
383
+ end
384
+
385
+ def update_velocity(p, dt)
386
+ grav = GRAVITY * 0.05 * dt
387
+ dvel = 4.0 * dt
388
+
389
+ case p.ramp_type
390
+ when :fire
391
+ p.vz += grav
392
+ when :explosion, :blob
393
+ p.vx += p.vx * dvel
394
+ p.vy += p.vy * dvel
395
+ p.vz += p.vz * dvel
396
+ p.vz -= grav
397
+ when :explosion2
398
+ p.vx -= p.vx * dt
399
+ p.vy -= p.vy * dt
400
+ p.vz -= p.vz * dt
401
+ p.vz -= grav
402
+ when :blob2
403
+ p.vx -= p.vx * dvel
404
+ p.vy -= p.vy * dvel
405
+ p.vz -= grav
406
+ else
407
+ p.vz -= grav * p.gravity_scale
408
+ end
409
+ end
410
+
411
+ def emit_rocket_trail_particle(x, y, z, dir_x, dir_y, dir_z, type)
412
+ case type
413
+ when 0, 1
414
+ ramp = (rand * 4).to_i + (type == 1 ? 2 : 0)
415
+ color = @palette.rgb(RAMP3[ramp])
416
+ emit(
417
+ x: x + (rand * 6).to_i - 3,
418
+ y: y + (rand * 6).to_i - 3,
419
+ z: z + (rand * 6).to_i - 3,
420
+ vx: 0.0, vy: 0.0, vz: 0.0,
421
+ r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
422
+ life: 2.0,
423
+ ramp: ramp,
424
+ ramp_type: :fire
425
+ )
426
+ when 2, 4
427
+ color = @palette.rgb(67 + (rand * 4).to_i)
428
+ emit(
429
+ x: x + (rand * 6).to_i - 3,
430
+ y: y + (rand * 6).to_i - 3,
431
+ z: z + (rand * 6).to_i - 3,
432
+ vx: 0.0, vy: 0.0, vz: 0.0,
433
+ r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
434
+ life: 2.0,
435
+ gravity_scale: 1.0
436
+ )
437
+ when 3, 5
438
+ @tracercount ||= 0
439
+ color_idx = type == 3 ? 52 + ((@tracercount & 4) << 1) : 230 + ((@tracercount & 4) << 1)
440
+ @tracercount += 1
441
+ vx = if (@tracercount & 1) == 1
442
+ 30.0 * dir_y
443
+ else
444
+ -30.0 * dir_y
445
+ end
446
+ vy = if (@tracercount & 1) == 1
447
+ 30.0 * -dir_x
448
+ else
449
+ 30.0 * dir_x
450
+ end
451
+ color = @palette.rgb(color_idx)
452
+ emit(
453
+ x: x, y: y, z: z,
454
+ vx: vx, vy: vy, vz: 0.0,
455
+ r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
456
+ life: 0.5
457
+ )
458
+ when 6
459
+ color = @palette.rgb((9 * 16) + 8 + (rand * 4).to_i)
460
+ emit(
461
+ x: x + (rand * 16).to_i - 8,
462
+ y: y + (rand * 16).to_i - 8,
463
+ z: z + (rand * 16).to_i - 8,
464
+ vx: 0.0, vy: 0.0, vz: 0.0,
465
+ r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
466
+ life: 0.3
467
+ )
468
+ end
469
+ end
470
+
471
+ def entity_particle_avelocities
472
+ @entity_particle_avelocities ||= GLAliasModel::ANORMS.map do
473
+ [
474
+ (rand * 256.0).to_i * 0.01,
475
+ (rand * 256.0).to_i * 0.01,
476
+ (rand * 256.0).to_i * 0.01
477
+ ]
478
+ end
479
+ end
480
+
481
+ def ramp_for_type(type)
482
+ case type
483
+ when :fire then RAMP3
484
+ when :explosion then RAMP1
485
+ when :explosion2 then RAMP2
486
+ end
487
+ end
488
+
489
+ def ramp_rate(type)
490
+ case type
491
+ when :fire then 5.0
492
+ when :explosion2 then 15.0
493
+ else 10.0
494
+ end
495
+ end
496
+
497
+ def ramp_expiry_size(type, ramp)
498
+ type == :fire ? 6 : ramp.size
499
+ end
500
+
161
501
  def emit(x:, y:, z:, vx:, vy:, vz:, r:, g:, b:, life:,
162
- gravity_scale: 0.0, ramp_type: nil)
502
+ gravity_scale: 0.0, ramp: 0.0, ramp_type: nil)
163
503
  return if @particles.size >= MAX_PARTICLES
164
504
 
165
505
  @particles << Particle.new(
166
506
  x, y, z, vx, vy, vz,
167
507
  r, g, b, 1.0,
168
- life, 0.0, ramp_type, gravity_scale
508
+ life, ramp.to_f, ramp_type, gravity_scale
169
509
  )
170
510
  end
171
511
  end
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "opengl"
4
+ require_relative "gl_warp_subdivision"
4
5
 
5
6
  module Quake
6
7
  module Renderer
7
8
  # Renders Quake sky using the two-layer scrolling technique.
8
9
  # Sky textures are 256x128, split into:
9
- # - Left half (128x128): solid background layer
10
- # - Right half (128x128): alpha foreground layer (index 0 = transparent)
10
+ # - Right half (128x128): solid background layer
11
+ # - Left half (128x128): alpha foreground layer (index 0 = transparent)
11
12
  class GLSky
13
+ include GLWarpSubdivision
14
+
12
15
  def initialize(level, palette, texture_manager)
13
16
  @level = level
14
17
  @palette = palette
@@ -107,17 +110,17 @@ module Quake
107
110
  h = miptex.height # 128
108
111
  half_w = w / 2 # 128
109
112
 
110
- # Extract left half (solid background)
113
+ # GLQuake uses the right half as the solid background layer.
111
114
  solid_pixels = String.new(capacity: half_w * h)
112
115
  h.times do |row|
113
- src_offset = row * w
116
+ src_offset = row * w + half_w
114
117
  solid_pixels << miptex.pixels[src_offset, half_w]
115
118
  end
116
119
 
117
- # Extract right half (alpha foreground)
120
+ # GLQuake uses the left half as the alpha foreground layer.
118
121
  alpha_pixels = String.new(capacity: half_w * h)
119
122
  h.times do |row|
120
- src_offset = row * w + half_w
123
+ src_offset = row * w
121
124
  alpha_pixels << miptex.pixels[src_offset, half_w]
122
125
  end
123
126
 
@@ -125,13 +128,81 @@ module Quake
125
128
  @alpha_tex = upload_texture(alpha_pixels, half_w, h, transparent: true)
126
129
  end
127
130
 
131
+ # Ruby port of tyrquake's QPic32_AlphaFix (qpic.c). Gives each fully
132
+ # transparent texel the average RGB of its non-transparent neighbours
133
+ # (wrapping around at the texture edges) to avoid wrong-colored fringes
134
+ # from GL_LINEAR blending. Alpha stays 0; texels with no non-transparent
135
+ # neighbours keep RGB 0.
136
+ def alpha_fix(rgba, width, height)
137
+ height.times do |y|
138
+ width.times do |x|
139
+ current = y * width + x
140
+
141
+ # Only modify completely transparent texels
142
+ next unless rgba.getbyte(current * 4 + 3) == 0
143
+
144
+ # Neighbour texel indexes are left to right:
145
+ # 0 1 2
146
+ # 3 * 4
147
+ # 5 6 7
148
+ neighbours = [
149
+ current - width - 1, current - width, current - width + 1,
150
+ current - 1, current + 1,
151
+ current + width - 1, current + width, current + width + 1
152
+ ]
153
+
154
+ # Handle edge cases (wrap around)
155
+ if x == 0
156
+ neighbours[0] += width
157
+ neighbours[3] += width
158
+ neighbours[5] += width
159
+ elsif x == width - 1
160
+ neighbours[2] -= width
161
+ neighbours[4] -= width
162
+ neighbours[7] -= width
163
+ end
164
+ if y == 0
165
+ neighbours[0] += width * height
166
+ neighbours[1] += width * height
167
+ neighbours[2] += width * height
168
+ elsif y == height - 1
169
+ neighbours[5] -= width * height
170
+ neighbours[6] -= width * height
171
+ neighbours[7] -= width * height
172
+ end
173
+
174
+ # Find the average colour of non-transparent neighbours
175
+ r = g = b = count = 0
176
+ neighbours.each do |n|
177
+ next if rgba.getbyte(n * 4 + 3) == 0
178
+
179
+ r += rgba.getbyte(n * 4)
180
+ g += rgba.getbyte(n * 4 + 1)
181
+ b += rgba.getbyte(n * 4 + 2)
182
+ count += 1
183
+ end
184
+
185
+ # Skip if no non-transparent neighbours
186
+ next if count == 0
187
+
188
+ rgba.setbyte(current * 4, r / count)
189
+ rgba.setbyte(current * 4 + 1, g / count)
190
+ rgba.setbyte(current * 4 + 2, b / count)
191
+ end
192
+ end
193
+ end
194
+
128
195
  def upload_texture(pixels, width, height, transparent:)
129
196
  rgba = String.new(capacity: width * height * 4)
130
197
  pixels.each_byte do |idx|
131
- r, g, b = @palette.rgb(idx)
132
- a = (transparent && idx == 0) ? 0 : 255
133
- rgba << r << g << b << a
198
+ if transparent && idx == 0
199
+ rgba << 0 << 0 << 0 << 0
200
+ else
201
+ r, g, b = @palette.rgb(idx)
202
+ rgba << r << g << b << 255
203
+ end
134
204
  end
205
+ alpha_fix(rgba, width, height) if transparent
135
206
 
136
207
  buf = "\0" * 4
137
208
  GL.GenTextures(1, buf)
@@ -157,7 +228,9 @@ module Quake
157
228
  next unless tex.name.start_with?("sky")
158
229
 
159
230
  verts = Bsp::FaceVertices.extract(@level, face)
160
- @sky_surfaces << { vertices: verts, face_index: face_index }
231
+ subdivide_polygon(verts).each do |poly_verts|
232
+ @sky_surfaces << { vertices: poly_verts, face_index: face_index }
233
+ end
161
234
  end
162
235
  puts "Found #{@sky_surfaces.size} sky surfaces"
163
236
  end