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.
- checksums.yaml +4 -4
- data/LICENSE +17 -0
- data/README.md +65 -4
- data/bin/doom +138 -25
- data/lib/doom/benchmark.rb +282 -0
- data/lib/doom/game/animations.rb +9 -2
- data/lib/doom/game/combat.rb +377 -105
- data/lib/doom/game/framebuffer_blitter.rb +38 -0
- data/lib/doom/game/geometry.rb +68 -0
- data/lib/doom/game/intermission.rb +231 -0
- data/lib/doom/game/item_pickup.rb +108 -60
- data/lib/doom/game/menu.rb +347 -0
- data/lib/doom/game/monster_ai.rb +304 -91
- data/lib/doom/game/player.rb +105 -0
- data/lib/doom/game/player_physics.rb +384 -0
- data/lib/doom/game/player_state.rb +47 -64
- data/lib/doom/game/random.rb +82 -0
- data/lib/doom/game/sector_actions.rb +466 -29
- data/lib/doom/game/sector_effects.rb +25 -18
- data/lib/doom/game/snapshot.rb +584 -0
- data/lib/doom/game/sound_engine.rb +176 -0
- data/lib/doom/game/state_hash.rb +125 -0
- data/lib/doom/game/ticcmd.rb +61 -0
- data/lib/doom/game/world.rb +420 -0
- data/lib/doom/map/data.rb +95 -0
- data/lib/doom/net/client.rb +204 -0
- data/lib/doom/net/desync_monitor.rb +101 -0
- data/lib/doom/net/game_server.rb +232 -0
- data/lib/doom/net/lockstep.rb +170 -0
- data/lib/doom/net/protocol.rb +350 -0
- data/lib/doom/net/session.rb +282 -0
- data/lib/doom/net/transport.rb +110 -0
- data/lib/doom/platform/gosu_window.rb +820 -640
- data/lib/doom/platform/sdl.rb +74 -0
- data/lib/doom/platform/window_logic.rb +61 -0
- data/lib/doom/render/font.rb +80 -0
- data/lib/doom/render/hardware_renderer.rb +547 -0
- data/lib/doom/render/ray_tracing/bvh.rb +103 -0
- data/lib/doom/render/ray_tracing/material_state.rb +66 -0
- data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
- data/lib/doom/render/ray_tracing_renderer.rb +940 -0
- data/lib/doom/render/renderer.rb +684 -330
- data/lib/doom/render/renderer_factory.rb +50 -0
- data/lib/doom/render/screen_melt.rb +71 -0
- data/lib/doom/render/spinel_native/kernel.rb +780 -0
- data/lib/doom/render/spinel_native_renderer.rb +162 -0
- data/lib/doom/render/status_bar.rb +14 -10
- data/lib/doom/render/weapon_renderer.rb +15 -17
- data/lib/doom/render/world_mesh.rb +270 -0
- data/lib/doom/render/zbuffer_renderer.rb +151 -0
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/flat.rb +1 -1
- data/lib/doom/wad/hud_graphics.rb +7 -3
- data/lib/doom/wad/palette.rb +3 -3
- data/lib/doom/wad/patch.rb +1 -1
- data/lib/doom/wad/reader.rb +59 -10
- data/lib/doom/wad/sound.rb +93 -0
- data/lib/doom/wad/sprite.rb +60 -34
- data/lib/doom/wad/texture.rb +5 -5
- data/lib/doom/wad_downloader.rb +38 -56
- data/lib/doom.rb +235 -12
- 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
|