doom 0.6.0 → 0.11.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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +17 -0
  3. data/README.md +65 -4
  4. data/bin/doom +138 -25
  5. data/lib/doom/benchmark.rb +282 -0
  6. data/lib/doom/game/animations.rb +9 -2
  7. data/lib/doom/game/combat.rb +377 -105
  8. data/lib/doom/game/framebuffer_blitter.rb +38 -0
  9. data/lib/doom/game/geometry.rb +68 -0
  10. data/lib/doom/game/intermission.rb +231 -0
  11. data/lib/doom/game/item_pickup.rb +108 -60
  12. data/lib/doom/game/menu.rb +347 -0
  13. data/lib/doom/game/monster_ai.rb +304 -91
  14. data/lib/doom/game/player.rb +105 -0
  15. data/lib/doom/game/player_physics.rb +384 -0
  16. data/lib/doom/game/player_state.rb +47 -64
  17. data/lib/doom/game/random.rb +82 -0
  18. data/lib/doom/game/sector_actions.rb +466 -29
  19. data/lib/doom/game/sector_effects.rb +25 -18
  20. data/lib/doom/game/snapshot.rb +584 -0
  21. data/lib/doom/game/sound_engine.rb +176 -0
  22. data/lib/doom/game/state_hash.rb +125 -0
  23. data/lib/doom/game/ticcmd.rb +61 -0
  24. data/lib/doom/game/world.rb +420 -0
  25. data/lib/doom/map/data.rb +95 -0
  26. data/lib/doom/net/client.rb +204 -0
  27. data/lib/doom/net/desync_monitor.rb +101 -0
  28. data/lib/doom/net/game_server.rb +232 -0
  29. data/lib/doom/net/lockstep.rb +170 -0
  30. data/lib/doom/net/protocol.rb +350 -0
  31. data/lib/doom/net/session.rb +282 -0
  32. data/lib/doom/net/transport.rb +110 -0
  33. data/lib/doom/platform/gosu_window.rb +820 -640
  34. data/lib/doom/platform/sdl.rb +74 -0
  35. data/lib/doom/platform/window_logic.rb +61 -0
  36. data/lib/doom/render/font.rb +80 -0
  37. data/lib/doom/render/hardware_renderer.rb +547 -0
  38. data/lib/doom/render/ray_tracing/bvh.rb +103 -0
  39. data/lib/doom/render/ray_tracing/material_state.rb +66 -0
  40. data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
  41. data/lib/doom/render/ray_tracing_renderer.rb +940 -0
  42. data/lib/doom/render/renderer.rb +684 -330
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +71 -0
  45. data/lib/doom/render/spinel_native/kernel.rb +780 -0
  46. data/lib/doom/render/spinel_native_renderer.rb +162 -0
  47. data/lib/doom/render/status_bar.rb +14 -10
  48. data/lib/doom/render/weapon_renderer.rb +15 -17
  49. data/lib/doom/render/world_mesh.rb +270 -0
  50. data/lib/doom/render/zbuffer_renderer.rb +151 -0
  51. data/lib/doom/version.rb +1 -1
  52. data/lib/doom/wad/flat.rb +1 -1
  53. data/lib/doom/wad/hud_graphics.rb +7 -3
  54. data/lib/doom/wad/palette.rb +3 -3
  55. data/lib/doom/wad/patch.rb +1 -1
  56. data/lib/doom/wad/reader.rb +59 -10
  57. data/lib/doom/wad/sound.rb +93 -0
  58. data/lib/doom/wad/sprite.rb +60 -34
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +235 -12
  62. metadata +100 -7
@@ -0,0 +1,547 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OpenGL is only needed by this hardware renderer; on runtimes without the gem
4
+ # (e.g. TruffleRuby/JRuby) the classic renderer still loads and runs.
5
+ begin
6
+ require 'opengl'
7
+ rescue LoadError
8
+ warn "[doom] opengl gem not available; the hardware renderer is disabled" if $VERBOSE
9
+ end
10
+
11
+ module Doom
12
+ module Render
13
+ # GPU rasterizer hosted inside Gosu's OpenGL context. The initial backend
14
+ # renders real world triangles with perspective projection and depth test.
15
+ class HardwareRenderer < Renderer
16
+ include OpenGL if defined?(OpenGL)
17
+
18
+ ANIMATED_DECORATIONS = %w[TBLU TGRN TRED SMIT COLU CAND CBRA].freeze
19
+ Batch = Struct.new(:material, :light, :surface, :masked, :buffer, :vertex_count)
20
+ VERTEX_STRIDE = 8 * 4
21
+ MAX_LIGHTS = 8
22
+ STATIC_LIGHTS = {
23
+ 44 => [0.25, 0.35, 1.0], 45 => [0.25, 1.0, 0.35],
24
+ 46 => [1.0, 0.22, 0.12], 34 => [1.0, 0.65, 0.22],
25
+ 35 => [1.0, 0.65, 0.22], 2028 => [1.0, 0.75, 0.35]
26
+ }.freeze
27
+
28
+ attr_reader :mesh
29
+
30
+ def initialize(...)
31
+ super
32
+ @mesh = WorldMesh.new(@map, @textures)
33
+ @geometry_signature = geometry_signature
34
+ @gl_textures = {}
35
+ @texture_dimensions = {}
36
+ @gpu_batches = nil
37
+ end
38
+
39
+ def hardware?
40
+ true
41
+ end
42
+
43
+ # Simulation still calls render_frame; hardware drawing must happen from
44
+ # Window#draw, inside Gosu.gl, so only reset the compatibility framebuffer.
45
+ def render_frame
46
+ signature = geometry_signature
47
+ if signature != @geometry_signature
48
+ @mesh = WorldMesh.new(@map, @textures)
49
+ @geometry_signature = signature
50
+ @gpu_batches_dirty = true
51
+ end
52
+ @framebuffer.fill(0)
53
+ end
54
+
55
+ def draw_hardware(viewport_width, viewport_height)
56
+ Gosu.gl do
57
+ load_opengl_library
58
+ glEnable(GL_DEPTH_TEST)
59
+ glEnable(GL_CULL_FACE)
60
+ glCullFace(GL_BACK)
61
+ glFrontFace(GL_CCW)
62
+ glEnable(GL_TEXTURE_2D)
63
+ glDepthFunc(GL_LEQUAL)
64
+ glClearColor(0.025, 0.025, 0.04, 1.0)
65
+ glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
66
+
67
+ draw_sky
68
+
69
+ glMatrixMode(GL_PROJECTION)
70
+ glLoadIdentity
71
+ aspect = viewport_width.to_f / viewport_height
72
+ glFrustum(-1.0, 1.0, -1.0 / aspect, 1.0 / aspect, 1.0, 16_384.0)
73
+ glMatrixMode(GL_MODELVIEW)
74
+ glLoadMatrixf(view_matrix.pack('f16'))
75
+
76
+ rebuild_gpu_batches if @gpu_batches.nil? || @gpu_batches_dirty
77
+ setup_lighting
78
+
79
+ # Invisible back faces must still occlude rooms when the camera is
80
+ # outside the playable map, matching the classic BSP solid-wall clip.
81
+ draw_occluder_depth_prepass
82
+ glEnable(GL_CULL_FACE)
83
+
84
+ draw_gpu_batches
85
+ glDisable(GL_LIGHTING)
86
+ glBindTexture(GL_TEXTURE_2D, 0)
87
+ glDisable(GL_TEXTURE_2D)
88
+ glDisable(GL_CULL_FACE)
89
+ # Sprites should be depth-tested against walls, not sliced by the
90
+ # floor they stand on. Preserve the color buffer, rebuild depth from
91
+ # walls only, then draw billboarded things.
92
+ glClear(GL_DEPTH_BUFFER_BIT)
93
+ draw_occluder_depth_prepass(include_ceilings: true)
94
+ draw_sprites
95
+ capture_frame if ENV['DOOM_GL_CAPTURE'] && !@frame_captured
96
+ glDisable(GL_DEPTH_TEST)
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def load_opengl_library
103
+ return if @opengl_loaded
104
+
105
+ if RUBY_PLATFORM.include?('darwin')
106
+ OpenGL.load_lib('OpenGL', '/System/Library/Frameworks/OpenGL.framework')
107
+ else
108
+ OpenGL.load_lib
109
+ end
110
+ @opengl_loaded = true
111
+ end
112
+
113
+ def geometry_signature
114
+ @map.sectors.flat_map { |sector| [sector.floor_height, sector.ceiling_height] }
115
+ end
116
+
117
+ def view_matrix
118
+ sin = @sin_angle
119
+ cos = @cos_angle
120
+ # Column-major OpenGL matrix: Doom world (x,y,z) -> view
121
+ # (right, up, -forward).
122
+ [
123
+ sin, 0.0, -cos, 0.0,
124
+ -cos, 0.0, -sin, 0.0,
125
+ 0.0, 1.0, 0.0, 0.0,
126
+ (-sin * @player_x) + (cos * @player_y),
127
+ -@player_z,
128
+ (cos * @player_x) + (sin * @player_y),
129
+ 1.0
130
+ ]
131
+ end
132
+
133
+ def rebuild_gpu_batches
134
+ delete_gpu_batches
135
+ groups = @mesh.triangles.group_by do |triangle|
136
+ surface = if triangle.normal[2].zero?
137
+ :wall
138
+ elsif triangle.normal[2].negative?
139
+ :ceiling
140
+ else
141
+ :floor
142
+ end
143
+ [triangle.material, triangle.light, surface, triangle.masked]
144
+ end
145
+ @gpu_batches = groups.map do |(material, light, surface, masked), triangles|
146
+ texture_for(material)
147
+ width, height = @current_texture_dimensions || [1.0, 1.0]
148
+ floats = triangles.flat_map do |triangle|
149
+ triangle.vertices.each_with_index.flat_map do |(x, y, z), index|
150
+ u, v = triangle.uvs[index]
151
+ [x, y, z, u / width, v / height, *triangle.normal]
152
+ end
153
+ end
154
+ data = floats.pack('f*')
155
+ ids = [0].pack('L')
156
+ glGenBuffers(1, ids)
157
+ buffer = ids.unpack1('L')
158
+ glBindBuffer(GL_ARRAY_BUFFER, buffer)
159
+ glBufferData(GL_ARRAY_BUFFER, data.bytesize, data, GL_STATIC_DRAW)
160
+ Batch.new(material, light, surface, masked, buffer, floats.size / 8)
161
+ end
162
+ glBindBuffer(GL_ARRAY_BUFFER, 0)
163
+ @gpu_batches_dirty = false
164
+ end
165
+
166
+ def delete_gpu_batches
167
+ return unless @gpu_batches&.any?
168
+
169
+ ids = @gpu_batches.map(&:buffer).pack('L*')
170
+ glDeleteBuffers(@gpu_batches.size, ids)
171
+ end
172
+
173
+ def draw_gpu_batches
174
+ glEnableClientState(GL_VERTEX_ARRAY)
175
+ glEnableClientState(GL_TEXTURE_COORD_ARRAY)
176
+ glEnableClientState(GL_NORMAL_ARRAY)
177
+ @gpu_batches.each do |batch|
178
+ if batch.masked
179
+ glDisable(GL_CULL_FACE)
180
+ glEnable(GL_ALPHA_TEST)
181
+ glAlphaFunc(GL_GREATER, 0.5)
182
+ else
183
+ glEnable(GL_CULL_FACE)
184
+ glDisable(GL_ALPHA_TEST)
185
+ end
186
+ texture_id = texture_for(batch.material)
187
+ glBindTexture(GL_TEXTURE_2D, texture_id || 0)
188
+ shade = (batch.light.to_f / 255.0).clamp(0.12, 1.0)
189
+ glColor3f(shade, shade, shade)
190
+ bind_batch(batch)
191
+ glDrawArrays(GL_TRIANGLES, 0, batch.vertex_count)
192
+ end
193
+ glDisableClientState(GL_TEXTURE_COORD_ARRAY)
194
+ glDisableClientState(GL_NORMAL_ARRAY)
195
+ glDisableClientState(GL_VERTEX_ARRAY)
196
+ glDisable(GL_ALPHA_TEST)
197
+ glEnable(GL_CULL_FACE)
198
+ glBindBuffer(GL_ARRAY_BUFFER, 0)
199
+ end
200
+
201
+ def bind_batch(batch)
202
+ glBindBuffer(GL_ARRAY_BUFFER, batch.buffer)
203
+ glVertexPointer(3, GL_FLOAT, VERTEX_STRIDE, 0)
204
+ glTexCoordPointer(2, GL_FLOAT, VERTEX_STRIDE, 12)
205
+ glNormalPointer(GL_FLOAT, VERTEX_STRIDE, 20)
206
+ end
207
+
208
+ def setup_lighting
209
+ glEnable(GL_LIGHTING)
210
+ glEnable(GL_NORMALIZE)
211
+ glEnable(GL_COLOR_MATERIAL)
212
+ glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
213
+ glLightModelfv(GL_LIGHT_MODEL_AMBIENT, [0.55, 0.55, 0.55, 1.0].pack('f4'))
214
+
215
+ lights = active_lights.first(MAX_LIGHTS)
216
+ MAX_LIGHTS.times do |index|
217
+ slot = GL_LIGHT0 + index
218
+ if (light = lights[index])
219
+ glEnable(slot)
220
+ glLightfv(slot, GL_POSITION, [light[:x], light[:y], light[:z], 1.0].pack('f4'))
221
+ glLightfv(slot, GL_DIFFUSE, [*light[:color], 1.0].pack('f4'))
222
+ glLightf(slot, GL_CONSTANT_ATTENUATION, 0.25)
223
+ glLightf(slot, GL_LINEAR_ATTENUATION, 0.004)
224
+ glLightf(slot, GL_QUADRATIC_ATTENUATION, 0.000018)
225
+ else
226
+ glDisable(slot)
227
+ end
228
+ end
229
+ end
230
+
231
+ def active_lights
232
+ lights = @map.things.filter_map do |thing|
233
+ color = STATIC_LIGHTS[thing.type]
234
+ next unless color
235
+
236
+ sector = @map.sector_at(thing.x, thing.y)
237
+ { x: thing.x.to_f, y: thing.y.to_f,
238
+ z: (sector&.floor_height || 0) + 40.0, color: color }
239
+ end
240
+ if @combat
241
+ @combat.projectiles.each do |projectile|
242
+ lights << { x: projectile.x, y: projectile.y, z: projectile.z,
243
+ color: [1.0, 0.45, 0.12] }
244
+ end
245
+ @combat.explosions.each do |explosion|
246
+ lights << { x: explosion[:x], y: explosion[:y], z: explosion[:z] || @player_z,
247
+ color: [1.0, 0.3, 0.05] }
248
+ end
249
+ end
250
+ if @view_player&.state&.attacking && @view_player.state.attack_frame <= 1
251
+ lights << { x: @view_player.x + (@view_player.cos_angle * 24.0),
252
+ y: @view_player.y + (@view_player.sin_angle * 24.0),
253
+ z: @view_player.z, color: [1.0, 0.72, 0.3] }
254
+ end
255
+ lights.sort_by { |light| ((light[:x] - @player_x)**2) + ((light[:y] - @player_y)**2) }
256
+ end
257
+
258
+ def draw_occluder_depth_prepass(include_ceilings: false)
259
+ glDisable(GL_CULL_FACE)
260
+ glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE)
261
+ glEnableClientState(GL_VERTEX_ARRAY)
262
+ @gpu_batches.each do |batch|
263
+ next unless batch.surface == :wall || (include_ceilings && batch.surface == :ceiling)
264
+ next if batch.masked
265
+
266
+ bind_batch(batch)
267
+ glDrawArrays(GL_TRIANGLES, 0, batch.vertex_count)
268
+ end
269
+ # Masked middle textures (grates/fences) write depth only for opaque
270
+ # texels, so sprites remain visible through their holes.
271
+ glEnable(GL_TEXTURE_2D)
272
+ glEnable(GL_ALPHA_TEST)
273
+ glAlphaFunc(GL_GREATER, 0.5)
274
+ glEnableClientState(GL_TEXTURE_COORD_ARRAY)
275
+ @gpu_batches.each do |batch|
276
+ next unless batch.masked && batch.surface == :wall
277
+
278
+ glBindTexture(GL_TEXTURE_2D, texture_for(batch.material) || 0)
279
+ bind_batch(batch)
280
+ glDrawArrays(GL_TRIANGLES, 0, batch.vertex_count)
281
+ end
282
+ glDisableClientState(GL_TEXTURE_COORD_ARRAY)
283
+ glDisable(GL_ALPHA_TEST)
284
+ glDisableClientState(GL_VERTEX_ARRAY)
285
+ glBindBuffer(GL_ARRAY_BUFFER, 0)
286
+ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE)
287
+ end
288
+
289
+ def capture_frame
290
+ require 'chunky_png'
291
+ viewport_data = [0, 0, 0, 0].pack('l4')
292
+ glGetIntegerv(GL_VIEWPORT, viewport_data)
293
+ _x, _y, width, height = viewport_data.unpack('l4')
294
+ pixels = Fiddle::Pointer.malloc(width * height * 4)
295
+ glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels)
296
+ bytes = pixels[0, width * height * 4]
297
+ image = ChunkyPNG::Image.new(width, height)
298
+ height.times do |screen_y|
299
+ source_y = height - 1 - screen_y
300
+ width.times do |screen_x|
301
+ offset = ((source_y * width) + screen_x) * 4
302
+ red, green, blue, alpha = bytes.byteslice(offset, 4).unpack('C4')
303
+ image[screen_x, screen_y] = ChunkyPNG::Color.rgba(red, green, blue, alpha)
304
+ end
305
+ end
306
+ image.save(ENV.fetch('DOOM_GL_CAPTURE'))
307
+ @frame_captured = true
308
+ end
309
+
310
+ def texture_for(name)
311
+ return nil if name.nil? || name.empty? || name == '-' || name == 'F_SKY1'
312
+
313
+ if @flats.key?(name)
314
+ resolved = @animations ? @animations.translate_flat(name) : name
315
+ source = @flats[resolved]
316
+ else
317
+ resolved = @animations ? @animations.translate_texture(name) : name
318
+ source = @textures[resolved]
319
+ end
320
+ unless source
321
+ @current_texture_dimensions = nil
322
+ return nil
323
+ end
324
+
325
+ width = source.width
326
+ height = source.height
327
+ @current_texture_dimensions = [width.to_f, height.to_f]
328
+ return @gl_textures[resolved] if @gl_textures.key?(resolved)
329
+
330
+ @texture_dimensions[resolved] = @current_texture_dimensions
331
+ indices = if source.respond_to?(:column_pixels)
332
+ Array.new(width * height) do |offset|
333
+ x = offset % width
334
+ y = offset / width
335
+ source.column_pixels(x)[y]
336
+ end
337
+ else
338
+ source.pixels
339
+ end
340
+ rgba = indices.map do |index|
341
+ index.nil? ? "\0\0\0\0" : [*@palette.colors[index], 255].pack('C4')
342
+ end.join
343
+ ids = [0].pack('L')
344
+ glGenTextures(1, ids)
345
+ id = ids.unpack1('L')
346
+ glBindTexture(GL_TEXTURE_2D, id)
347
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST)
348
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST)
349
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
350
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)
351
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA,
352
+ GL_UNSIGNED_BYTE, rgba)
353
+ @gl_textures[resolved] = id
354
+ end
355
+
356
+ def draw_sky
357
+ texture_id = texture_for('SKY1')
358
+ return unless texture_id
359
+
360
+ glDisable(GL_DEPTH_TEST)
361
+ glDisable(GL_CULL_FACE)
362
+ glMatrixMode(GL_PROJECTION)
363
+ glLoadIdentity
364
+ glMatrixMode(GL_MODELVIEW)
365
+ glLoadIdentity
366
+ glBindTexture(GL_TEXTURE_2D, texture_id)
367
+ glColor3f(1.0, 1.0, 1.0)
368
+ # Match Renderer::SKY_TEXTUREMID/SKY_YSCALE: Doom projects 200 sky
369
+ # texels over the 240-pixel view, wrapping the 128px SKY1 vertically.
370
+ bottom_v = 200.0 / 128.0
371
+ glBegin(GL_QUAD_STRIP)
372
+ (0..SCREEN_WIDTH).each do |column|
373
+ screen_x = ((column.to_f / SCREEN_WIDTH) * 2.0) - 1.0
374
+ column_angle = @player_angle - Math.atan2(column - HALF_WIDTH, @projection)
375
+ u = column_angle * 2.0 / Math::PI
376
+ glTexCoord2f(u, bottom_v)
377
+ glVertex3f(screen_x, -1.0, 0.0)
378
+ glTexCoord2f(u, 0.0)
379
+ glVertex3f(screen_x, 1.0, 0.0)
380
+ end
381
+ glEnd
382
+ glEnable(GL_DEPTH_TEST)
383
+ glEnable(GL_CULL_FACE)
384
+ end
385
+
386
+ def draw_sprites
387
+ return unless @sprites
388
+
389
+ glEnable(GL_TEXTURE_2D)
390
+ glEnable(GL_BLEND)
391
+ glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
392
+ glEnable(GL_ALPHA_TEST)
393
+ glAlphaFunc(GL_GREATER, 0.01)
394
+
395
+ @map.things.each_with_index do |thing, index|
396
+ next if @hidden_things && @hidden_things[index]
397
+
398
+ sprite = sprite_for_thing(thing, index)
399
+ next unless sprite
400
+
401
+ id = sprite_texture(sprite)
402
+ next unless id
403
+
404
+ sector = @map.sector_at(thing.x, thing.y)
405
+ floor = sector ? sector.floor_height : 0
406
+ top = floor + sprite.top_offset
407
+ bottom = top - sprite.height
408
+ right_x = @sin_angle
409
+ right_y = -@cos_angle
410
+ left_x = thing.x - (right_x * sprite.left_offset)
411
+ left_y = thing.y - (right_y * sprite.left_offset)
412
+ right_edge_x = left_x + (right_x * sprite.width)
413
+ right_edge_y = left_y + (right_y * sprite.width)
414
+
415
+ before_sprite_draw(thing, sprite, sector)
416
+ glBindTexture(GL_TEXTURE_2D, id)
417
+ glColor3f(1.0, 1.0, 1.0)
418
+ glBegin(GL_QUADS)
419
+ sprite_vertex(left_x, left_y, bottom, 0.0, 1.0)
420
+ sprite_vertex(right_edge_x, right_edge_y, bottom, 1.0, 1.0)
421
+ sprite_vertex(right_edge_x, right_edge_y, top, 1.0, 0.0)
422
+ sprite_vertex(left_x, left_y, top, 0.0, 0.0)
423
+ glEnd
424
+ end
425
+
426
+ draw_projectiles
427
+
428
+ glDisable(GL_ALPHA_TEST)
429
+ glDisable(GL_BLEND)
430
+ glDisable(GL_TEXTURE_2D)
431
+ end
432
+
433
+ # Fireballs and rockets in flight. Unlike map things these are dynamic and
434
+ # live on @combat, not @map.things, so draw_sprites skips them; billboard
435
+ # them centred on their flight height (they fly above the floor).
436
+ def draw_projectiles
437
+ return unless @combat && @sprites
438
+
439
+ @combat.projectiles.each do |projectile|
440
+ sprite = projectile_sprite(projectile)
441
+ next unless sprite
442
+
443
+ id = sprite_texture(sprite)
444
+ next unless id
445
+
446
+ sector = @map.sector_at(projectile.x, projectile.y)
447
+ top = projectile.z + (sprite.height / 2.0)
448
+ bottom = top - sprite.height
449
+ right_x = @sin_angle
450
+ right_y = -@cos_angle
451
+ left_x = projectile.x - (right_x * sprite.left_offset)
452
+ left_y = projectile.y - (right_y * sprite.left_offset)
453
+ right_edge_x = left_x + (right_x * sprite.width)
454
+ right_edge_y = left_y + (right_y * sprite.width)
455
+
456
+ before_sprite_draw(nil, sprite, sector)
457
+ glBindTexture(GL_TEXTURE_2D, id)
458
+ glColor3f(1.0, 1.0, 1.0)
459
+ glBegin(GL_QUADS)
460
+ sprite_vertex(left_x, left_y, bottom, 0.0, 1.0)
461
+ sprite_vertex(right_edge_x, right_edge_y, bottom, 1.0, 1.0)
462
+ sprite_vertex(right_edge_x, right_edge_y, top, 1.0, 0.0)
463
+ sprite_vertex(left_x, left_y, top, 0.0, 0.0)
464
+ glEnd
465
+ end
466
+ end
467
+
468
+ def projectile_sprite(projectile)
469
+ prefix = projectile.sprite_prefix || 'MISL'
470
+ frame = %w[A B][@leveltime / 4 % 2]
471
+ viewer_angle = Math.atan2(projectile.y - @player_y, projectile.x - @player_x)
472
+ travel_angle = Math.atan2(projectile.dy, projectile.dx)
473
+ @sprites.get_frame_rotated(prefix, frame, viewer_angle, travel_angle) ||
474
+ @sprites.get_frame_by_prefix(prefix, 'A')
475
+ end
476
+
477
+ # Extension point for renderers that shade billboard fragments. The
478
+ # ordinary rasterizer intentionally keeps Doom's full-bright sprites.
479
+ def before_sprite_draw(_thing, _sprite, _sector); end
480
+
481
+ def sprite_vertex(x, y, z, u, v)
482
+ glTexCoord2f(u, v)
483
+ # World coordinates: the same model-view matrix as the static VBOs
484
+ # transforms billboards exactly once.
485
+ glVertex3f(x, y, z)
486
+ end
487
+
488
+ def sprite_texture(sprite)
489
+ key = [:sprite, sprite.name]
490
+ return @gl_textures[key] if @gl_textures.key?(key)
491
+
492
+ indices = Array.new(sprite.width * sprite.height) do |offset|
493
+ x = offset % sprite.width
494
+ y = offset / sprite.width
495
+ sprite.column_pixels(x)[y]
496
+ end
497
+ rgba = indices.map do |index|
498
+ index.nil? ? "\0\0\0\0" : [*@palette.colors[index], 255].pack('C4')
499
+ end.join
500
+ @gl_textures[key] = upload_texture(sprite.width, sprite.height, rgba)
501
+ end
502
+
503
+ def sprite_for_thing(thing, index)
504
+ angle = Math.atan2(thing.y - @player_y, thing.x - @player_x)
505
+ if @combat&.dead?(index)
506
+ @combat.death_sprite(index, thing.type, angle, thing.angle)
507
+ elsif @monster_ai && (monster = @monster_ai.monster_by_thing_idx[index])&.active
508
+ prefix = @sprites.prefix_for(thing.type)
509
+ if monster.attacking
510
+ frames = Game::MonsterAI::ATTACK_FRAMES[prefix]
511
+ frame = frames && frames[(monster.attack_frame_tic / Game::MonsterAI::ATTACK_FRAME_TICS)
512
+ .clamp(0, frames.size - 1)]
513
+ else
514
+ frame = %w[A B C D][@leveltime / 4 % 4]
515
+ end
516
+ (frame && @sprites.get_frame(thing.type, frame, angle, thing.angle)) ||
517
+ @sprites.get_rotated(thing.type, angle, thing.angle)
518
+ else
519
+ prefix = @sprites.prefix_for(thing.type)
520
+ if ANIMATED_DECORATIONS.include?(prefix)
521
+ frame = %w[A B C D][@leveltime / 6 % 4]
522
+ @sprites.get_frame(thing.type, frame, angle, thing.angle) ||
523
+ @sprites.get_rotated(thing.type, angle, thing.angle)
524
+ else
525
+ # This path respects THING_DEFAULT_FRAME (e.g. PLAYN/PLAYW for
526
+ # corpses and blood pools) instead of blindly selecting PLAYA-D.
527
+ @sprites.get_rotated(thing.type, angle, thing.angle)
528
+ end
529
+ end
530
+ end
531
+
532
+ def upload_texture(width, height, rgba)
533
+ ids = [0].pack('L')
534
+ glGenTextures(1, ids)
535
+ id = ids.unpack1('L')
536
+ glBindTexture(GL_TEXTURE_2D, id)
537
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST)
538
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST)
539
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
540
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
541
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA,
542
+ GL_UNSIGNED_BYTE, rgba)
543
+ id
544
+ end
545
+ end
546
+ end
547
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Render
5
+ module RayTracing
6
+ # Stackless, pre-order bounding volume hierarchy for GPU traversal.
7
+ # Internal nodes point at their children and every node stores an escape
8
+ # index, allowing GLSL 1.20 to traverse without a dynamic stack.
9
+ class Bvh
10
+ Node = Struct.new(:minimum, :maximum, :start, :count, :escape, :left, :right,
11
+ keyword_init: true) do
12
+ def leaf?
13
+ start >= 0
14
+ end
15
+ end
16
+
17
+ attr_reader :nodes, :triangles, :triangle_order, :leaf_size
18
+
19
+ def initialize(source_triangles, leaf_size: 8)
20
+ @leaf_size = leaf_size
21
+ @nodes = []
22
+ @triangles = []
23
+ @triangle_order = []
24
+ build(source_triangles.each_with_index.to_a)
25
+ end
26
+
27
+ def compatible?(source_triangles)
28
+ @triangle_order.size == source_triangles.size
29
+ end
30
+
31
+ # Door/lift motion changes vertices but not triangle identity. Refit
32
+ # bounds bottom-up while preserving the GPU-friendly leaf ordering.
33
+ def refit(source_triangles)
34
+ raise ArgumentError, 'triangle topology changed' unless compatible?(source_triangles)
35
+
36
+ @triangles = @triangle_order.map { |index| source_triangles[index] }
37
+ (@nodes.size - 1).downto(0) do |index|
38
+ node = @nodes[index]
39
+ if node.leaf?
40
+ node.minimum, node.maximum = bounds(@triangles[node.start, node.count])
41
+ else
42
+ left = @nodes[node.left]
43
+ right = @nodes[node.right]
44
+ node.minimum = axes { |axis| [left.minimum[axis], right.minimum[axis]].min }
45
+ node.maximum = axes { |axis| [left.maximum[axis], right.maximum[axis]].max }
46
+ end
47
+ end
48
+ self
49
+ end
50
+
51
+ # Three RGBA texels per node: min/escape, max/start, count/padding.
52
+ def packed_floats
53
+ @nodes.flat_map do |node|
54
+ [*node.minimum, node.escape.to_f,
55
+ *node.maximum, node.start.to_f,
56
+ node.count.to_f, 0.0, 0.0, 0.0]
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def build(entries)
63
+ node_index = @nodes.size
64
+ @nodes << nil
65
+ triangles = entries.map(&:first)
66
+ minimum, maximum = bounds(triangles)
67
+ if entries.size <= @leaf_size
68
+ start = @triangles.size
69
+ @triangles.concat(triangles)
70
+ @triangle_order.concat(entries.map(&:last))
71
+ @nodes[node_index] = Node.new(minimum: minimum, maximum: maximum,
72
+ start: start, count: entries.size,
73
+ escape: node_index + 1)
74
+ return node_index
75
+ end
76
+
77
+ centers = triangles.map { |triangle| axes { |axis| triangle.vertices.sum { |v| v[axis] } / 3.0 } }
78
+ extents = axes { |axis| centers.map { |center| center[axis] }.minmax.then { |a, b| b - a } }
79
+ split_axis = extents.each_with_index.max_by(&:first).last
80
+ sorted = entries.zip(centers).sort_by { |_entry, center| center[split_axis] }.map(&:first)
81
+ middle = sorted.size / 2
82
+ left = build(sorted[0...middle])
83
+ right = build(sorted[middle..])
84
+ @nodes[node_index] = Node.new(minimum: minimum, maximum: maximum,
85
+ start: -1, count: 0, escape: @nodes.size,
86
+ left: left, right: right)
87
+ node_index
88
+ end
89
+
90
+ def bounds(triangles)
91
+ points = triangles.flat_map(&:vertices)
92
+ minimum = axes { |axis| points.min_by { |point| point[axis] }[axis] - 0.01 }
93
+ maximum = axes { |axis| points.max_by { |point| point[axis] }[axis] + 0.01 }
94
+ [minimum, maximum]
95
+ end
96
+
97
+ def axes(&)
98
+ 3.times.map(&)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end