Skip to content

Instantly share code, notes, and snippets.

@nilium
Created March 11, 2014 22:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nilium/9496419 to your computer and use it in GitHub Desktop.
Save nilium/9496419 to your computer and use it in GitHub Desktop.
Quickly thrown-together script for converting MD3 files to OBJ files. Each frame gets its own file. This script is very slow.
#!/usr/bin/env ruby -WKu
require 'stringio'
module MD3View
def run_view(*) ; raise NotImplementedError ; end
end
begin
require("#{File.dirname $0}/md3view.rb")
rescue LoadError => ex
$stderr.puts ex
end
# Simple IO ops for reading MD3 files. May be included in anything that
# implements a read(numbytes)->string method.
module QIO
class <<self
def __define_read_op__(name, size, packing)
__send__(:define_method, name, -> (n = nil) do
pkg = self.read((n || 1) * size).unpack(packing)
n.nil? ? pkg[0] : pkg
end)
end
private :__define_read_op__
# write_md3_frame_obj writes the md3 data's frame to io in the Wavefront OBJ
# format.
def write_md3_frame_obj(io, data, frame)
io.puts "o #{data.name}"
vcount = 1
data.surfaces.each do |surf|
io.puts "g #{surf.name}"
surf.xyznormals[frame].each do |vert|
io.puts "v #{vert.origin.join ' '}"
end
surf.xyznormals[frame].each do |vert|
io.puts "vn #{vert.normal.map{|i|-i}.join ' '}"
end
surf.texcoords.each do |st|
io.puts "vt #{st.join ' '}"
end
surf.triangles.each do |tri|
io.puts "f #{tri.to_a.reverse.map { |i| i += vcount; [i, i, i].join '/' }.join ' '}"
end
vcount += surf.texcoords.length
end
end
end
MD3_XYZ_SCALE = 1.0 / 64.0
FIXED_TO_FLOAT_BLK = -> (s16) { s16 * MD3_XYZ_SCALE }
FIXED_TO_SPHERE_BLK = -> (components) do
zenith = components[0].to_f
azimuth = components[1].to_f
latitude = zenith * (Math::PI * 2.0) / 255.0
longitude = azimuth * (Math::PI * 2.0) / 255.0
latcos = Math.cos(latitude)
components[0] = Math.cos(longitude) * Math.sin(latitude)
components[1] = Math.sin(longitude) * latcos
components[2] = latcos
components.each_with_index do |k, i|
if (1.0 - k).abs < 1e-6
components[(i + 1) % 3] = 0.0
components[(i + 2) % 3] = 0.0
break
end
end
components
end
__define_read_op__(:read_u8, 1, 'C*')
__define_read_op__(:read_s16, 2, 's<*')
__define_read_op__(:read_s32, 4, 'l<*')
__define_read_op__(:read_f32, 4, 'e*')
def read_vec3_f32(n = nil)
pkg = read_f32((n || 1) * 3).each_slice(3).to_a
n.nil? ? pkg[0] : pkg
end
def read_vec3_f16(n = nil, normal = false)
pkg = read_s16((n || 1) * 3)
pkg = pkg.map!(&FIXED_TO_FLOAT_BLK).each_slice(3).to_a
n.nil? ? pkg[0] : pkg
end
def read_sphere_normal(n = nil)
pkg = read_u8((n || 1) * 2).each_slice(2).to_a
pkg.each(&FIXED_TO_SPHERE_BLK)
n.nil? ? pkg[0] : pkg
end
def read_nul_string(bytes)
str = read(bytes)
str[0 ... (str.index("\0") || str.length)]
end
end
class QStringIO < StringIO
include QIO
end
class MD3Data
MD3_IDENT = "IDP3"
SURF_IDENT = MD3_IDENT
MAX_QPATH = 64
Chunk = Struct.new(:offset, :count)
# vertices is an array of length chunks.frames.count containing arrays of vertices.
# triangles is an array of length chunks.triangles.count containing 3-long arrays of vertex indices.
Surface = Struct.new(:name, :chunks, :frames, :shaders, :triangles, :texcoords, :xyznormals)
Frame = Struct.new(:min, :max, :origin, :radius, :name)
Vertex = Struct.new(:origin, :normal)
Shader = Struct.new(:name, :index)
Tag = Struct.new(:name, :frames)
TagFrame = Struct.new(:origin, :orientation)
Orientation = Struct.new(:x, :y, :z)
PRIVATE_ATTRS = %i[ io data ]
attr_reader *PRIVATE_ATTRS
private *PRIVATE_ATTRS
attr_reader *%i[ name version flags chunks frames surfaces tags ]
def frame_name(frame)
frames[frame].name
end
def initialize(data)
@data = data
@io = QStringIO.new(data, 'rb')
start = io.tell
ident = io.read(4)
raise "Invalid MD3 header IDENT: #{ident}" unless ident == MD3_IDENT
@version = io.read_s32
@name = io.read_nul_string(MAX_QPATH)
@flags = io.read_s32
raise "Flags non-zero (#{@flags}) -- not sure how to proceed" unless @flags == 0
@chunks = {
header: Chunk[start],
frames: Chunk[nil, io.read_s32],
tags: Chunk[nil, io.read_s32],
surfaces: Chunk[nil, io.read_s32],
eof: Chunk[]
}
skin_count = io.read_s32
raise "Skin count (#{skin_count}) > 0 -- not sure how to proceed" unless skin_count == 0
chunks[:frames].offset = start + io.read_s32
chunks[:tags].offset = start + io.read_s32
chunks[:surfaces].offset = start + io.read_s32
@frames = at_chunk(chunks[:frames]) { |c| read_frames(c.count) }
@surfaces = at_chunk(chunks[:surfaces]) { |c| read_surfaces(c.count) }
@tags = at_chunk(chunks[:tags]) { |c| read_tags(c.count) }
@io = nil
@data = nil
end
def export_obj(io)
puts "Not implemented"
end
private
def read_frame
Frame[
io.read_vec3_f32,
io.read_vec3_f32,
io.read_vec3_f32,
io.read_f32,
io.read_nul_string(16)
]
end
def read_frames(count)
Array.new(count) { read_frame }
end
def read_triangle
io.read_s32(3)
end
def read_triangles(count)
Array.new(count) { read_triangle }
end
def read_vertex
Vertex[io.read_vec3_f16, io.read_sphere_normal].tap do |v|
t = v.origin[1]
v.origin[1] = -v.origin[2]
v.origin[2] = t
t = v.normal[1]
v.normal[1] = -v.normal[2]
v.normal[2] = t
end
end
def read_vertices(count)
Array.new(count) { read_vertex }
end
def read_shader
Shader[io.read_nul_string(MAX_QPATH), io.read_s32]
end
def read_shaders(count)
Array.new(count) { read_shader }
end
def read_texcoord
pkg = io.read_f32(2)
pkg[1] = 1.0 - pkg[1]
pkg
end
def read_texcoords(count)
Array.new(count) { read_texcoord }
end
def read_surface
start = io.tell
ident = io.read(4)
raise "Invalid surface IDENT: #{ident}" unless ident == SURF_IDENT
name = io.read_nul_string(MAX_QPATH)
flags = io.read_s32
raise "Flags non-zero (#{flags}) -- not sure how to proceed" unless flags == 0
frames = io.read_s32
num_verts = 0
surf_chunks = {
start: Chunk[start],
shaders: Chunk[nil, io.read_s32],
xyznormal: Chunk[nil, (num_verts = io.read_s32)],
texcoords: Chunk[nil, num_verts],
triangles: Chunk[nil, io.read_s32],
eos: Chunk[]
}
surf_chunks[:triangles].offset = start + io.read_s32
surf_chunks[:shaders].offset = start + io.read_s32
surf_chunks[:texcoords].offset = start + io.read_s32
surf_chunks[:xyznormal].offset = start + io.read_s32
surf_chunks[:eos].offset = start + io.read_s32
Surface[
name,
surf_chunks,
frames,
at_chunk(surf_chunks[:shaders]) { |c| read_shaders(c.count) },
at_chunk(surf_chunks[:triangles]) { |c| read_triangles(c.count) },
at_chunk(surf_chunks[:texcoords]) { |c| read_texcoords(c.count) },
at_chunk(surf_chunks[:xyznormal]) do |c|
Array.new(frames) { read_vertices(c.count) }
end
].tap do
# Check that I've read to the end of the surface
pos = io.tell
raise "Failed to read entire surface" if pos < surf_chunks[:eos].offset
raise "Read past end of surface" if pos > surf_chunks[:eos].offset
end
end
def read_surfaces(count)
Array.new(count) { read_surface }
end
def read_tag
[
io.read_nul_string(MAX_QPATH),
TagFrame[
io.read_vec3_f32,
Orientation[*io.read_vec3_f32(3)]
]
]
end
def read_tags(count)
tags = []
tmap = Hash.new { |h, n| h[n] = Tag[n, []].tap { |t| tags << t } }
(0 ... count).each {
name, tframe = read_tag
tag = tmap[name]
tag.frames << tag
}
tags
end
def at_chunk(chunk, return_to_current = false)
raise ArgumentError, "No block given" unless block_given?
pos = io.tell
begin
io.seek(chunk.offset)
yield(chunk)
ensure
io.seek(pos) if return_to_current
end
end
end
module MD3Convert
def load_md3(md3path)
data = md3path == '-' ? $stdin.read : File.open(md3path, 'r') { |io| io.read }
MD3Data.new(data)
end
def log_md3_spec(data, indent_n = 0)
indent = ' ' * indent_n
puts <<-EOS
#{indent}MD3 #{data.name.empty? ? '(no name)' : data.name}:
#{indent} Frames: #{data.frames.length.to_s.rjust(16)}
#{indent} Tags[#{data.tags.length}]:
EOS
data.tags.each do |tag|
puts <<-EOS
#{indent} #{tag.name.empty? ? '(no name)' : tag.name}
EOS
end
puts "#{indent} Surfaces[#{data.surfaces.length}]:"
data.surfaces.each do |surf|
puts <<-EOS
#{indent} Surface #{surf.name.empty? ? '(no name)' : surf.name}:
#{indent} Frames: #{surf.frames.to_s.rjust(10)}
#{indent} Vertices: #{surf.xyznormals[0].length.to_s.rjust(10)}
#{indent} Triangles: #{surf.triangles.length.to_s.rjust(10)}
#{indent} Shaders[#{surf.shaders.length}]:
EOS
surf.shaders.each do |shader|
puts <<-EOS
#{indent} #{shader.index.to_s(10).ljust(6)} #{shader.name.empty? ? '(no name)' : shader.name}
EOS
end
end
end
def run_convert(argv, spec = false)
argv.each { |md3path|
basename = File.basename(md3path, File.extname(md3path))
dir = "#{File.dirname(md3path)}/#{basename}_frames"
basepath = "#{dir}/#{basename}"
md3data = load_md3(md3path)
if spec
puts "File #{md3path.inspect}"
log_md3_spec(md3data, 2)
next
end
mkdirp(dir)
num_frames = md3data.frames.length
(0 ... num_frames).each do |frame|
suffix = if num_frames > 1 ; "+#{frame}" ; end
puts "Writing frame to '#{basepath}#{suffix}.obj'"
export_obj = File.open("#{basepath}#{suffix}.obj", 'w') do |io|
QIO.write_md3_frame_obj(io, md3data, frame)
end
end
}
end
extend self
end
if __FILE__ == $0
# mkdirp checks that every directory in dirpath exists and is a directory. If
# the directory doesn't exist, it creates it. If the path exists but isn't a
# directory, an ArgumentError is raised since the dirpath is invalid.
def mkdirp(dirpath)
dirpath.split('/').each_with_object('') do |comp, accum|
accum << "#{comp}/"
next if comp == '.'
next if File.directory? accum
if File.exists? accum
raise ArgumentError, "#{accum.inspect} already exists and is not a directory"
end
Dir.mkdir(accum)
end
end
argv = Marshal.load(Marshal.dump(ARGV))
# help text
if argv.empty? || argv.include?('-help')
$stderr.puts <<-HELPTEXT.gsub(/^ {4}/, '')
md3.rb [-convert] file.md3 [-] [...]
Converts the given MD3 files to Wavefront OBJ files. OBJ files are
written to 'dir/of/file/basename_frames/basename+frame.obj' to avoid
cluttering up the MD3 file's directory too much.
If '-' is passed as an argument to md3.rb, the file will be read from
standard input.
md3.rb -spec [file.md3] [-] [...]
md3.rb -view [file.md3 | -]
Loads the given md3 file and displays it in a window.
md3.rb -help
If '-help' is specified, this text is displayed and the program will
exit without reading anything.
HELPTEXT
exit 0
end
command =
case argv.first
when '-convert'
argv.shift
MD3Convert.run_convert(argv, false)
when '-spec'
argv.shift
MD3Convert.run_convert(argv, true)
when '-view'
argv.shift
MD3View.run_view(argv)
else
MD3Convert.run_convert(argv, false)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment