glaze 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 59e34d1077ab8534452db27b99282791ab2926a4804a14e78d24171d95925b72
4
+ data.tar.gz: 295b3479c2d06dec5e24db6320c55958d47f7a79f14ac08c266dbd4d449c43c0
5
+ SHA512:
6
+ metadata.gz: 9aab13dcda3d98a0aa6489cda4275e5f29dba5285344955f87df64df550d3162e5b11a0b3a18cb697591b66d97fe0e5602ce1a177c760db9b8d7a64abaa8d066
7
+ data.tar.gz: 906add6ec06430f52fa2388fc58edc7b0f35748f9863ef3fa5b556bbec9570b5d6373bbd1c4917403616cc4168c834842add4dea90bc048fc30c091342a9cf84
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-09-23
6
+
7
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ydah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # Glaze
2
+
3
+ [![Gem version](https://badge.fury.io/rb/glaze.svg)](https://rubygems.org/gems/glaze)
4
+ [![Downloads](https://img.shields.io/gem/dt/glaze?label=downloads)](https://rubygems.org/gems/glaze)
5
+ [![CI](https://github.com/rbgfx/glaze/actions/workflows/ci.yml/badge.svg)](https://github.com/rbgfx/glaze/actions/workflows/ci.yml)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D3.1-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
7
+ [![License](https://img.shields.io/badge/license-MIT-750014.svg)](LICENSE.txt)
8
+
9
+ > Live shader coding for Ruby, from a saved file to a window, PNG, or WebGPU page.
10
+
11
+ Glaze loads shader definitions written in Ruby, compiles them through rlsl, and
12
+ renders them with rbgl. It supports live reload, headless output, parameter
13
+ metadata, and WebGPU export.
14
+
15
+ **[Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Shader files](#shader-files) · [Development](#development)**
16
+
17
+ ## Features
18
+
19
+ - Live reload for shader files without losing the current frame on a bad edit.
20
+ - CPU rendering for portable and headless workflows.
21
+ - Metal rendering and GPU pixel capture on supported macOS hosts.
22
+ - PNG screenshots, frame sequences, and standalone WebGPU HTML export.
23
+ - Typed parameters with defaults, ranges, steps, and an optional Twiddle panel.
24
+ - Built-in uniforms for time, frame, mouse, resolution, and fragment position.
25
+
26
+ ## Installation
27
+
28
+ Add Glaze to your Gemfile:
29
+
30
+ ~~~ruby
31
+ gem "glaze"
32
+ ~~~
33
+
34
+ Then run:
35
+
36
+ ~~~sh
37
+ bundle install
38
+ ~~~
39
+
40
+ Or install the released gem:
41
+
42
+ ~~~sh
43
+ gem install glaze
44
+ ~~~
45
+
46
+ The CPU renderer and screenshot commands require a C compiler. Metal rendering
47
+ is available on macOS with a Metal-capable device.
48
+
49
+ ## Quick start
50
+
51
+ Run the included examples:
52
+
53
+ ~~~sh
54
+ glaze run examples/plasma.rb
55
+ glaze shot examples/plasma.rb --time 1.5 --size 640x360 -o shot.png
56
+ glaze record examples/plasma.rb --seconds 2 --fps 30 -o frames
57
+ glaze export examples/plasma.rb plasma.html
58
+ ~~~
59
+
60
+ Run a fixed number of headless CPU frames:
61
+
62
+ ~~~sh
63
+ glaze run examples/plasma.rb --renderer cpu --backend file --frames 2 --output-dir frames
64
+ ~~~
65
+
66
+ ## Shader files
67
+
68
+ A shader file contains one <code>Glaze.shader</code> definition:
69
+
70
+ ~~~ruby
71
+ Glaze.shader(:gradient) do
72
+ params do
73
+ float :speed, default: 1.0, range: 0.0..4.0
74
+ end
75
+
76
+ fragment do |point, resolution, uniforms|
77
+ value = point[0] / resolution[0] + uniforms.time * uniforms.speed
78
+ [value % 1.0, 0.2, 0.4, 1.0]
79
+ end
80
+ end
81
+ ~~~
82
+
83
+ Fragments use rlsl syntax. Texture parameters are supported by the Metal
84
+ runner; the WebGPU exporter embeds the referenced PNG bytes in its HTML.
85
+
86
+ ## Development
87
+
88
+ ~~~sh
89
+ bundle install
90
+ bundle exec rake verify
91
+ ~~~
92
+
93
+ ## License
94
+
95
+ [MIT](LICENSE.txt)
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec) do |task|
7
+ task.ruby_opts = %w[-I../tessel/lib -I../rlsl/lib -I../rbgl/lib -I../larb/lib -I../twiddle/lib -I../metaco/lib]
8
+ end
9
+
10
+ task default: :spec
11
+ task verify: :spec
data/docs/spikes.md ADDED
@@ -0,0 +1,15 @@
1
+ # Glaze implementation checks
2
+
3
+ These checks record the results of the design spikes that can run without a
4
+ browser or a Metal device.
5
+
6
+ | Check | Result | Evidence |
7
+ |---|---|---|
8
+ | Forward a fragment block through another object | Pass | `spec/glaze_spec.rb` covers `Definition#rlsl_builder` and helper/function forwarding. |
9
+ | Load the same file twice after editing it | Pass | `Loader` uses `load(path, true)` and clears the definition registry; `Watcher` hashes Ruby tokens. |
10
+ | Pack integer and vector uniforms | Pass for CPU and source generation | CPU rendering and RLSL MSL/WGSL generation are covered by the Glaze and RLSL suites. GPU execution requires a Metal session. |
11
+ | Resolve WGSL integer and boolean layout | Pass | `Export::UniformLayout` uses 4-byte scalar slots and 16-byte vector alignment; export specs cover generated bindings. |
12
+ | Use a Cocoa native handle with the Metal runner | Wired, GPU validation pending | `Runners::Metal` calls `build_metal_shader`, `prepare`, and `render_metal`; the mandatory GPU workflow is `run_metal`. |
13
+
14
+ The last check must be repeated on a logged-in macOS session with a Metal
15
+ device. Headless CI intentionally omits it.
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ Glaze.shader :gradient do
4
+ fragment do |frag_coord, resolution, _u|
5
+ uv = frag_coord / resolution
6
+ vec3(uv.x, uv.y, 0.25)
7
+ end
8
+ end
Binary file
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ Glaze.shader :plasma do
4
+ params { float :speed, default: 1.0, range: 0.0..4.0, step: 0.1 }
5
+
6
+ fragment do |frag_coord, resolution, u|
7
+ uv = frag_coord / resolution
8
+ r = sin(u.time * u.speed + uv.x * 6.28) * 0.5 + 0.5
9
+ g = sin(u.time * u.speed + uv.y * 6.28) * 0.5 + 0.5
10
+ b = sin(u.time * u.speed + (uv.x + uv.y) * 6.28) * 0.5 + 0.5
11
+ vec3(r, g, b)
12
+ end
13
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ Glaze.shader :textured do
4
+ params { texture :noise, file: "noise.png" }
5
+ fragment do |frag_coord, resolution, u|
6
+ texture(u.noise, frag_coord / resolution).xyz
7
+ end
8
+ end
data/exe/glaze ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "tempfile"
6
+ require "glaze"
7
+
8
+ command = ARGV.shift
9
+ case command
10
+ when "new"
11
+ name = ARGV.shift || "shader"
12
+ raise ArgumentError, "shader name must contain only letters, digits and underscores" unless name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
13
+ path = File.join("shaders", "#{name}.rb")
14
+ FileUtils.mkdir_p(File.dirname(path))
15
+ File.write(path, "Glaze.shader :#{name} do\n fragment do |frag_coord, resolution, u|\n uv = frag_coord / resolution\n vec3(uv.x, uv.y, 0.2)\n end\nend\n")
16
+ puts path
17
+ when "run"
18
+ path = ARGV.first&.start_with?("-") ? nil : ARGV.shift
19
+ size = [640, 360]
20
+ backend = :auto
21
+ renderer = :auto
22
+ fps = 60
23
+ frames = nil
24
+ output_dir = "."
25
+ from_png = nil
26
+ panel = false
27
+ OptionParser.new do |opts|
28
+ opts.on("--size SIZE") { |value| size = value.split("x").map(&:to_i) }
29
+ opts.on("--backend NAME") { |value| backend = value.to_sym }
30
+ opts.on("--renderer NAME") { |value| renderer = value.to_sym }
31
+ opts.on("--fps N", Integer) { |value| fps = value }
32
+ opts.on("--frames N", Integer) { |value| frames = value }
33
+ opts.on("--output-dir PATH") { |value| output_dir = value }
34
+ opts.on("--from-png PATH") { |value| from_png = value }
35
+ opts.on("--panel") { panel = true }
36
+ end.parse!(ARGV)
37
+ path ||= ARGV.shift
38
+ raise ArgumentError, "size must be WIDTHxHEIGHT" unless size.length == 2 && size.all?(&:positive?)
39
+ options = { width: size[0], height: size[1], backend: backend, renderer: renderer, fps: fps, frames: frames, output_dir: output_dir, panel: panel }
40
+ if from_png
41
+ source = Tessel.read(from_png).metadata.fetch("glaze:source")
42
+ Tempfile.create(["glaze-recovered-", ".rb"]) do |file|
43
+ file.write(source)
44
+ file.flush
45
+ Glaze.run_file(file.path, **options)
46
+ end
47
+ else
48
+ raise ArgumentError, "shader file is required" unless path
49
+ Glaze.run_file(path, **options)
50
+ end
51
+ when "shot"
52
+ path = ARGV.shift
53
+ output = "shot.png"
54
+ time = 0.0
55
+ size = [640, 360]
56
+ OptionParser.new do |opts|
57
+ opts.on("-o PATH") { |value| output = value }
58
+ opts.on("--time SECONDS", Float) { |value| time = value }
59
+ opts.on("--size SIZE") { |value| size = value.split("x").map(&:to_i) }
60
+ end.parse!(ARGV)
61
+ Glaze.capture(Glaze.load_file(path), output, time: time, size: size)
62
+ puts output
63
+ when "record"
64
+ path = ARGV.shift
65
+ output_dir = "frames"
66
+ fps = 30
67
+ seconds = 1.0
68
+ size = [640, 360]
69
+ OptionParser.new do |opts|
70
+ opts.on("-o DIR") { |value| output_dir = value }
71
+ opts.on("--fps N", Integer) { |value| fps = value }
72
+ opts.on("--seconds N", Float) { |value| seconds = value }
73
+ opts.on("--size SIZE") { |value| size = value.split("x").map(&:to_i) }
74
+ end.parse!(ARGV)
75
+ raise ArgumentError, "fps, seconds and dimensions must be positive" unless fps.positive? && seconds.positive? && size.length == 2 && size.all?(&:positive?)
76
+ definition = Glaze.load_file(path)
77
+ runner = Glaze::Runners::CPU.new(definition)
78
+ FileUtils.mkdir_p(output_dir)
79
+ (seconds * fps).ceil.times do |frame|
80
+ image = runner.render(width: size[0], height: size[1], time: frame.to_f / fps, frame: frame)
81
+ Glaze.save_capture(image, definition, File.join(output_dir, format("frame_%05d.png", frame)))
82
+ end
83
+ puts output_dir
84
+ when "export"
85
+ path = ARGV.shift
86
+ output = ARGV.shift || "#{File.basename(path, ".rb")}.html"
87
+ Glaze.export_webgpu(Glaze.load_file(path), output)
88
+ puts output
89
+ else
90
+ warn "usage: glaze new NAME | run FILE [--backend NAME] [--renderer NAME] [--panel] [--size WxH] | shot FILE [-o PATH] | record FILE [-o DIR] | export FILE [PATH]"
91
+ exit 1
92
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../glaze"
data/lib/glaze/dsl.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../glaze"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../glaze"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../glaze"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glaze
4
+ VERSION = "0.1.0"
5
+ end
data/lib/glaze.rb ADDED
@@ -0,0 +1,654 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "cgi"
7
+ require "ripper"
8
+ require "tessel"
9
+
10
+ require_relative "glaze/version"
11
+
12
+ module Glaze
13
+ class Error < StandardError; end
14
+ BUILT_INS = %i[time frame mouse resolution frag_coord u].freeze
15
+ TYPE_SIZES = { float: [4, 4], int: [4, 4], bool: [4, 4], vec2: [8, 8], vec3: [12, 16], vec4: [16, 16] }.freeze
16
+
17
+ Param = Struct.new(:name, :type, :default, :range, :step, :file, keyword_init: true)
18
+
19
+ class ParamsContext
20
+ attr_reader :params
21
+
22
+ def initialize
23
+ @params = []
24
+ end
25
+
26
+ TYPE_SIZES.keys.each do |type|
27
+ define_method(type) do |name, default: nil, range: nil, step: nil|
28
+ name = name.to_sym
29
+ raise ArgumentError, "uniform name is reserved: #{name}" if BUILT_INS.include?(name)
30
+ raise ArgumentError, "duplicate uniform: #{name}" if @params.any? { |param| param.name == name }
31
+ default = default_for(type, default)
32
+ validate(type, default, range, step)
33
+ @params << Param.new(name: name, type: type, default: default, range: range, step: step)
34
+ end
35
+ end
36
+
37
+ def texture(name, file:)
38
+ name = name.to_sym
39
+ raise ArgumentError, "uniform name is reserved: #{name}" if BUILT_INS.include?(name)
40
+ raise ArgumentError, "duplicate uniform: #{name}" if @params.any? { |param| param.name == name }
41
+ raise ArgumentError, "texture file is required" if file.to_s.empty?
42
+ @params << Param.new(name: name, type: :sampler2D, file: file.to_s)
43
+ end
44
+
45
+ def uniforms(&block)
46
+ params(&block)
47
+ end
48
+
49
+ private
50
+
51
+ def default_for(type, value)
52
+ return value unless value.nil?
53
+ type == :bool ? false : type == :int ? 0 : type.to_s.start_with?("vec") ? Array.new(type.to_s.delete_prefix("vec").to_i, 0.0) : 0.0
54
+ end
55
+
56
+ def validate(type, default, range, step)
57
+ case type
58
+ when :float
59
+ raise TypeError, "float uniform default must be numeric" unless default.is_a?(Numeric)
60
+ when :int
61
+ raise TypeError, "int uniform default must be an integer" unless default.is_a?(Integer)
62
+ when :bool
63
+ raise TypeError, "bool uniform default must be true or false" unless [true, false].include?(default)
64
+ else
65
+ size = type.to_s.delete_prefix("vec").to_i
66
+ raise TypeError, "vector uniform default must be an array" unless default.is_a?(Array)
67
+ raise ArgumentError, "vector uniform default has the wrong length" unless default.length == size
68
+ raise TypeError, "vector uniform default must be numeric" unless default.all? { |value| value.is_a?(Numeric) }
69
+ end
70
+ if range
71
+ raise ArgumentError, "uniform range is only valid for numeric uniforms" unless %i[float int].include?(type)
72
+ raise TypeError, "uniform range must be a Range" unless range.is_a?(Range)
73
+ raise TypeError, "uniform range endpoints must be numeric" unless range.begin.is_a?(Numeric) && range.end.is_a?(Numeric)
74
+ raise ArgumentError, "uniform range must increase" unless range.begin < range.end
75
+ raise ArgumentError, "uniform range must be finite" unless range.begin.to_f.finite? && range.end.to_f.finite?
76
+ raise ArgumentError, "uniform default is outside its range" unless range.cover?(default)
77
+ end
78
+ if step
79
+ raise ArgumentError, "uniform step is only valid for numeric uniforms" unless %i[float int].include?(type)
80
+ raise TypeError, "uniform step must be numeric" unless step.is_a?(Numeric)
81
+ raise ArgumentError, "uniform step must be positive and finite" unless step.positive? && step.to_f.finite?
82
+ raise TypeError, "int uniform step must be an integer" if type == :int && !step.is_a?(Integer)
83
+ end
84
+ end
85
+ end
86
+
87
+ class Definition
88
+ attr_reader :name, :params, :fragment_block, :helper_blocks, :function_blocks, :source
89
+
90
+ def initialize(name)
91
+ @name = name.to_sym
92
+ @params = []
93
+ @helper_blocks = []
94
+ @function_blocks = []
95
+ end
96
+
97
+ def params(&block)
98
+ return @params unless block
99
+
100
+ context = ParamsContext.new
101
+ context.instance_eval(&block)
102
+ duplicate = context.params.find { |param| @params.any? { |existing| existing.name == param.name } }
103
+ raise ArgumentError, "duplicate uniform: #{duplicate.name}" if duplicate
104
+ @params.concat(context.params)
105
+ end
106
+
107
+ alias uniforms params
108
+
109
+ def fragment(&block)
110
+ @fragment_block = block
111
+ @source = block&.source_location
112
+ end
113
+
114
+ def helpers(&block) = @helper_blocks << block
115
+ def functions(&block) = @function_blocks << block
116
+
117
+ def param(name)
118
+ @params.find { |param| param.name == name.to_sym }
119
+ end
120
+
121
+ def rlsl_builder
122
+ require "rlsl"
123
+ definition = self
124
+ builder = RLSL::ShaderBuilder.new(@name)
125
+ builder.uniforms do
126
+ float :time
127
+ int :frame
128
+ vec4 :mouse
129
+ definition.params.each { |param| public_send(param.type, param.name) }
130
+ end
131
+ @function_blocks.each { |block| builder.functions(&block) }
132
+ @helper_blocks.each { |block| builder.helpers(&block) }
133
+ raise Error, "shader #{@name} has no fragment" unless @fragment_block
134
+ builder.fragment(&@fragment_block)
135
+ builder
136
+ end
137
+ end
138
+
139
+ class Loader
140
+ Result = Struct.new(:definition, :error, :location, keyword_init: true) do
141
+ def ok? = !definition.nil? && error.nil?
142
+ end
143
+
144
+ def load(path)
145
+ Result.new(definition: Glaze.load_file(path))
146
+ rescue SyntaxError, StandardError => e
147
+ Result.new(error: e, location: e.backtrace&.first)
148
+ end
149
+ end
150
+
151
+ module Runners
152
+ class ParamPanel
153
+ attr_reader :ui
154
+
155
+ def initialize(definition)
156
+ require "twiddle"
157
+ @definition = definition
158
+ @ui = Twiddle::Context.new
159
+ end
160
+
161
+ def render(image, events:, values:)
162
+ @ui.frame(events: events) do |ui|
163
+ ui.window("Parameters") do
164
+ @definition.params.each do |param|
165
+ if param.range && %i[float int].include?(param.type)
166
+ maximum = param.range.exclude_end? ? param.range.end - (param.type == :int ? 1 : Float::EPSILON) : param.range.end
167
+ values[param.name] = ui.slider(param.name.to_s, values[param.name], param.range.begin..maximum)
168
+ else
169
+ ui.label("#{param.name}: #{values[param.name].inspect}")
170
+ end
171
+ end
172
+ end
173
+ end
174
+ @ui.render(image)
175
+ end
176
+ end
177
+
178
+ class CPU
179
+ attr_reader :definition
180
+
181
+ def initialize(definition)
182
+ raise Error, "texture params require the Metal runner" if definition.params.any? { |param| param.type == :sampler2D }
183
+ @definition = definition
184
+ @shader = definition.rlsl_builder.compile_and_load
185
+ end
186
+
187
+ def render(width:, height:, time: 0.0, frame: 0, mouse: [0.0, 0.0, 0.0, 0.0], params: {})
188
+ buffer = "\0".b * (width * height * 4)
189
+ uniforms = @definition.params.to_h { |param| [param.name, params.fetch(param.name, param.default)] }
190
+ @shader.render(buffer, width, height, uniforms.merge(time: time, frame: frame, mouse: mouse))
191
+ # rlsl's C renderer writes top-down BGRA; Tessel uses top-down RGBA.
192
+ rgba = buffer.unpack("L<*").map { |pixel| (pixel & 0xff00_ff00) | ((pixel & 0xff) << 16) | ((pixel >> 16) & 0xff) }.pack("L<*")
193
+ Tessel::Image.from_rgba(width, height, rgba)
194
+ end
195
+ end
196
+
197
+ class Metal
198
+ attr_reader :definition
199
+
200
+ def initialize(definition, handle)
201
+ @definition = definition
202
+ @handle = handle
203
+ @textures = {}
204
+ begin
205
+ base = File.dirname(File.expand_path(definition.instance_variable_get(:@file) || "."))
206
+ definition.params.each do |param|
207
+ next unless param.type == :sampler2D
208
+ image = Tessel.read(File.expand_path(param.file, base))
209
+ @textures[param.name] = Metaco.texture_create(handle, image.width, image.height, image.bytes)
210
+ end
211
+ @shader = definition.rlsl_builder.build_metal_shader
212
+ @shader.prepare(handle)
213
+ rescue Exception
214
+ close
215
+ raise
216
+ end
217
+ end
218
+
219
+ def render(width:, height:, time: 0.0, frame: 0, mouse: [0.0, 0.0, 0.0, 0.0], params: {})
220
+ uniforms = @definition.params.reject { |param| param.type == :sampler2D }.to_h { |param| [param.name, params.fetch(param.name, param.default)] }
221
+ @shader.render_metal(@handle, width, height, uniforms.merge(time: time, frame: frame, mouse: mouse), textures: @textures)
222
+ end
223
+
224
+ def read_image(width, height)
225
+ Tessel::Image.from_rgba(width, height, Metaco.read_pixels(@handle))
226
+ end
227
+
228
+ def close
229
+ @textures.each_value { |texture| Metaco.texture_destroy(texture) }
230
+ @textures.clear
231
+ end
232
+ end
233
+
234
+ class Window
235
+ def initialize(path, width:, height:, backend: :auto, renderer: :auto, fps: 60, frames: nil, output_dir: ".", panel: false)
236
+ requested_backend = backend.to_sym
237
+ @renderer = renderer.to_sym
238
+ if @renderer == :auto && %i[cpu metal].include?(requested_backend)
239
+ @renderer = requested_backend
240
+ requested_backend = requested_backend == :metal ? :cocoa : :auto
241
+ end
242
+ @path, @width, @height, @backend, @fps, @frames, @output_dir, @panel = path, width, height, requested_backend, fps, frames, output_dir, panel
243
+ raise ArgumentError, "unknown RBGL backend: #{@backend}" unless %i[auto file cocoa x11 wayland].include?(@backend)
244
+ raise ArgumentError, "renderer must be :auto, :cpu, or :metal" unless %i[auto cpu metal].include?(@renderer)
245
+ raise ArgumentError, "fps must be positive" unless @fps.positive?
246
+ end
247
+
248
+ def run
249
+ require "rbgl"
250
+ loaded = Loader.new.load(@path)
251
+ raise loaded.error unless loaded.ok?
252
+ params = ParamState.new(loaded.definition)
253
+ watcher = Watcher.new(@path)
254
+ watcher.changed?
255
+ options = @backend == :file ? { format: :ppm, output_dir: @output_dir, max_frames: @frames || 1 } : {}
256
+ window = RBGL::GUI::Window.new(width: @width, height: @height, title: "glaze: #{loaded.definition.name}", backend: @backend, **options)
257
+ metal = @renderer == :metal || (@renderer == :auto && window.metal_available?)
258
+ raise LoadError, "Metal backend is unavailable" if metal && !window.metal_available?
259
+ renderer = metal ? Metal : CPU
260
+ runner = renderer == Metal ? Metal.new(loaded.definition, window.native_handle) : CPU.new(loaded.definition)
261
+ panel = @panel && renderer == CPU ? ParamPanel.new(loaded.definition) : nil
262
+ input = InputState.new(width: @width, height: @height)
263
+ clock = Clock.new
264
+ show_hud = true
265
+ reload_error = nil
266
+ until window.should_close?
267
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
268
+ save_shot = false
269
+ events = Array(window.poll_events_raw)
270
+ events.each do |event|
271
+ case event[:type]
272
+ when :mouse_move then input.mouse_move(event[:x], event[:y])
273
+ when :mouse_press then input.mouse_press(event[:x], event[:y])
274
+ when :mouse_release then input.mouse_release(event[:x], event[:y])
275
+ when :key_press
276
+ key = event[:key]
277
+ key = key.to_sym if key.respond_to?(:to_sym)
278
+ save_shot = true if key == :s || event[:char] == "s"
279
+ clock.paused? ? clock.resume : clock.pause if key == :space
280
+ clock.reset if key == :r
281
+ show_hud = !show_hud if key == :h || event[:char] == "h"
282
+ params.select(params.selected + 1) if %i[tab down].include?(key)
283
+ params.select(params.selected - 1) if key == :up
284
+ params.adjust(1) if key == :right
285
+ params.adjust(-1) if key == :left
286
+ when :resize
287
+ @width, @height = event[:width], event[:height]
288
+ input = InputState.new(width: @width, height: @height)
289
+ end
290
+ end
291
+ if watcher.changed?
292
+ candidate = Loader.new.load(@path)
293
+ begin
294
+ raise candidate.error unless candidate.ok?
295
+ next_runner = renderer == Metal ? Metal.new(candidate.definition, window.native_handle) : CPU.new(candidate.definition)
296
+ previous_runner = runner
297
+ runner = next_runner
298
+ previous_runner.close if previous_runner.is_a?(Metal)
299
+ params = ParamState.new(candidate.definition)
300
+ panel = ParamPanel.new(candidate.definition) if panel
301
+ reload_error = nil
302
+ warn "reloaded #{@path}"
303
+ rescue StandardError => e
304
+ reload_error = e.message
305
+ warn "reload failed: #{e.message}"
306
+ end
307
+ end
308
+ clock.tick
309
+ image = runner.render(width: @width, height: @height, time: clock.time, frame: clock.frame, mouse: input.mouse, params: params.values)
310
+ draw_hud(image, runner.definition, params, reload_error) if show_hud && image
311
+ panel&.render(image, events: events, values: params.values)
312
+ window.set_pixels(image.bytes) if image
313
+ if save_shot
314
+ FileUtils.mkdir_p("shots")
315
+ destination = File.join("shots", "#{runner.definition.name}-#{Time.now.strftime('%Y%m%d-%H%M%S')}.png")
316
+ if runner.is_a?(Metal)
317
+ Glaze.save_capture(runner.read_image(@width, @height), runner.definition, destination, params: params.values)
318
+ else
319
+ Glaze.capture(runner.definition, destination, time: clock.time, params: params.values, size: [@width, @height])
320
+ end
321
+ end
322
+ delay = 1.0 / @fps - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
323
+ sleep(delay) if delay.positive? && @backend != :file
324
+ end
325
+ ensure
326
+ runner.close if runner.is_a?(Metal)
327
+ window&.close
328
+ end
329
+
330
+ private
331
+
332
+ def draw_hud(image, definition, params, reload_error)
333
+ require "glyphic"
334
+ parameter = definition.params[params.selected]
335
+ text = if reload_error
336
+ "reload failed: #{reload_error}"
337
+ elsif parameter
338
+ "#{parameter.name}: #{params.values[parameter.name].inspect}"
339
+ end
340
+ return unless text
341
+
342
+ image.fill_rect(0, 0, image.width, [20, image.height].min, [0, 0, 0, 190], blend: :alpha)
343
+ Glyphic.default.draw(image, 4, 3, text.slice(0, [image.width / 6, 1].max), color: [255, 255, 255, 255])
344
+ rescue LoadError
345
+ nil
346
+ end
347
+ end
348
+ end
349
+
350
+ class Clock
351
+ attr_reader :time, :frame
352
+
353
+ def initialize(clock: Process.method(:clock_gettime))
354
+ @clock = clock
355
+ @started = now
356
+ @time = 0.0
357
+ @frame = 0
358
+ @paused = false
359
+ end
360
+
361
+ def tick
362
+ @time = now - @started unless @paused
363
+ @frame += 1
364
+ self
365
+ end
366
+
367
+ def pause = (@paused = true)
368
+ def resume = (@started = now - @time; @paused = false)
369
+ def paused? = @paused
370
+ def reset = (@started = now; @time = 0.0; @frame = 0)
371
+ def seek(seconds) = (@started = now - seconds.to_f; @time = seconds.to_f)
372
+
373
+ private
374
+
375
+ def now
376
+ @clock.call(Process::CLOCK_MONOTONIC)
377
+ end
378
+ end
379
+
380
+ class InputState
381
+ attr_reader :mouse, :keys
382
+
383
+ def initialize(width: 1, height: 1)
384
+ @width = width
385
+ @height = height
386
+ @mouse = [0.0, 0.0, 0.0, 0.0]
387
+ @keys = {}
388
+ end
389
+
390
+ def mouse_move(x, y)
391
+ @mouse[0] = x
392
+ @mouse[1] = @height - 1 - y
393
+ end
394
+
395
+ def mouse_press(x, y)
396
+ mouse_move(x, y)
397
+ @mouse[2] = @mouse[0]
398
+ @mouse[3] = @mouse[1]
399
+ end
400
+
401
+ def mouse_release(x, y)
402
+ mouse_move(x, y)
403
+ @mouse[2] = -@mouse[2].abs
404
+ @mouse[3] = -@mouse[3].abs
405
+ end
406
+
407
+ def key(key, down: true)
408
+ @keys[key.to_sym] = down
409
+ end
410
+ end
411
+
412
+ class ParamState
413
+ attr_reader :values, :selected
414
+
415
+ def initialize(definition)
416
+ @values = definition.params.to_h { |param| [param.name, param.default] }
417
+ @params = definition.params
418
+ @selected = 0
419
+ end
420
+
421
+ def select(index)
422
+ @selected = @params.empty? ? 0 : [[index.to_i, 0].max, @params.length - 1].min
423
+ end
424
+
425
+ def adjust(amount)
426
+ param = @params[@selected]
427
+ return unless param && %i[float int].include?(param.type)
428
+ step = param.step || (param.type == :int ? 1 : 0.01)
429
+ value = @values[param.name] + amount.to_f * step
430
+ if param.range
431
+ maximum = param.range.exclude_end? ? param.range.end - (param.type == :int ? 1 : Float::EPSILON) : param.range.end
432
+ value = [[value, param.range.begin].max, maximum].min
433
+ end
434
+ @values[param.name] = param.type == :int ? value.round : value
435
+ end
436
+ end
437
+
438
+ class Watcher
439
+ def initialize(path)
440
+ @path = path
441
+ @signature = nil
442
+ end
443
+
444
+ def changed?
445
+ signature = normalized_signature
446
+ changed = signature != @signature
447
+ @signature = signature
448
+ changed
449
+ rescue Errno::ENOENT
450
+ false
451
+ end
452
+
453
+ private
454
+
455
+ def normalized_signature
456
+ tokens = Ripper.lex(File.read(@path)).reject { |(_, type, _, _)| %i[on_sp on_nl on_ignored_nl on_comment].include?(type) }
457
+ Digest::SHA256.hexdigest(tokens.map { |(_, type, text, _)| "#{type}:#{text}" }.join)
458
+ rescue SyntaxError, EncodingError
459
+ Digest::SHA256.file(@path).hexdigest
460
+ end
461
+ end
462
+
463
+ module Export
464
+ module UniformLayout
465
+ module_function
466
+
467
+ def build(params)
468
+ offset = 0
469
+ result = {}
470
+ params.each do |param|
471
+ size, alignment = TYPE_SIZES.fetch(param.type)
472
+ offset = align(offset, alignment)
473
+ result[param.name] = { offset: offset, type: param.type }
474
+ offset += size
475
+ end
476
+ { fields: result, size: align(offset, 16) }
477
+ end
478
+
479
+ def align(value, alignment)
480
+ (value + alignment - 1) / alignment * alignment
481
+ end
482
+ private_class_method :align
483
+ end
484
+
485
+ module WebGPU
486
+ module_function
487
+
488
+ def write(definition, path)
489
+ value_params = definition.params.reject { |param| param.type == :sampler2D }
490
+ texture_params = definition.params.select { |param| param.type == :sampler2D }
491
+ base = File.dirname(File.expand_path(definition.instance_variable_get(:@file) || "."))
492
+ images = texture_params.map do |param|
493
+ image = Tessel.read(File.expand_path(param.file, base))
494
+ { name: param.name, data: "data:image/png;base64,#{[Tessel::PNG.encode(image)].pack('m0')}" }
495
+ end
496
+ layout = UniformLayout.build([
497
+ Param.new(name: :resolution, type: :vec2),
498
+ Param.new(name: :time, type: :float),
499
+ Param.new(name: :frame, type: :int),
500
+ Param.new(name: :mouse, type: :vec4),
501
+ *value_params
502
+ ])
503
+ source = definition.rlsl_builder.build_wgsl_shader
504
+ params = value_params.map do |param|
505
+ { name: param.name, type: param.type, default: param.default,
506
+ range: param.range && [param.range.begin, param.range.end], exclude_end: param.range&.exclude_end?, step: param.step }
507
+ end
508
+ html = <<~HTML
509
+ <!doctype html><meta charset="utf-8"><title>#{CGI.escapeHTML(definition.name.to_s)}</title>
510
+ <style>html,body{margin:0;width:100%;height:100%;background:#111;color:#fff;font:14px sans-serif}canvas{width:100%;height:100%;display:block}#panel{position:fixed;top:12px;left:12px;background:#000a;padding:12px}label{display:block}</style>
511
+ <canvas></canvas><div id="panel"><div id="status"></div><div id="params"></div></div>
512
+ <script>
513
+ const shaderSource = #{JSON.generate(source).gsub("</", "<\\/")};
514
+ const layout = #{JSON.generate(layout)};
515
+ const params = #{JSON.generate(params)};
516
+ const images = #{JSON.generate(images)};
517
+ const canvas = document.querySelector('canvas');
518
+ const status = document.querySelector('#status');
519
+ const values = Object.fromEntries(params.map(p => [p.name, p.default]));
520
+ for (const p of params) {
521
+ if (!p.range || !['float', 'int'].includes(p.type)) continue;
522
+ const label = document.createElement('label');
523
+ const input = document.createElement('input');
524
+ input.type = 'range'; input.min = p.range[0]; input.max = p.exclude_end ? (p.type === 'int' ? p.range[1] - 1 : p.range[1] - Number.EPSILON) : p.range[1];
525
+ input.step = p.step ?? (p.type === 'int' ? 1 : 'any'); input.value = p.default;
526
+ const caption = document.createElement('span');
527
+ input.oninput = () => { values[p.name] = Number(input.value); caption.textContent = `${p.name}: ${input.value}`; };
528
+ input.oninput(); label.append(caption, input); document.querySelector('#params').append(label);
529
+ }
530
+ const mouse = [0, 0, 0, 0];
531
+ function move(e) { const r = canvas.getBoundingClientRect(); mouse[0] = (e.clientX-r.left)*canvas.width/r.width; mouse[1] = canvas.height-(e.clientY-r.top)*canvas.height/r.height; }
532
+ canvas.onpointermove = move;
533
+ canvas.onpointerdown = e => { move(e); mouse[2] = mouse[0]; mouse[3] = mouse[1]; };
534
+ canvas.onpointerup = e => { move(e); mouse[2] = -Math.abs(mouse[2]); mouse[3] = -Math.abs(mouse[3]); };
535
+ async function start() {
536
+ if (!navigator.gpu) throw new Error('WebGPU is not available');
537
+ const adapter = await navigator.gpu.requestAdapter();
538
+ if (!adapter) throw new Error('No WebGPU adapter');
539
+ const device = await adapter.requestDevice();
540
+ const context = canvas.getContext('webgpu');
541
+ const format = navigator.gpu.getPreferredCanvasFormat();
542
+ context.configure({device, format, alphaMode: 'opaque'});
543
+ const compute = device.createComputePipeline({layout:'auto',compute:{module:device.createShaderModule({code:shaderSource}),entryPoint:'main'}});
544
+ const imageBindings = [];
545
+ for (const [index, image] of images.entries()) {
546
+ const bitmap = await createImageBitmap(await (await fetch(image.data)).blob());
547
+ const texture = device.createTexture({size:[bitmap.width,bitmap.height],format:'rgba8unorm',usage:GPUTextureUsage.TEXTURE_BINDING|GPUTextureUsage.COPY_DST});
548
+ device.queue.copyExternalImageToTexture({source:bitmap},{texture},[bitmap.width,bitmap.height]);
549
+ imageBindings.push({binding:2+index*2,resource:texture.createView()},
550
+ {binding:3+index*2,resource:device.createSampler({magFilter:'linear',minFilter:'linear',addressModeU:'clamp-to-edge',addressModeV:'clamp-to-edge'})});
551
+ bitmap.close();
552
+ }
553
+ const presentSource = `@group(0) @binding(0) var image: texture_2d<f32>;
554
+ @vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4<f32>{var p=array<vec2<f32>,3>(vec2(-1.0,-1.0),vec2(3.0,-1.0),vec2(-1.0,3.0));return vec4(p[i],0.0,1.0);}
555
+ @fragment fn fs(@builtin(position) p:vec4<f32>)->@location(0) vec4<f32>{return textureLoad(image,vec2<i32>(p.xy),0);}`;
556
+ const present = device.createRenderPipeline({layout:'auto',vertex:{module:device.createShaderModule({code:presentSource}),entryPoint:'vs'},fragment:{module:device.createShaderModule({code:presentSource}),entryPoint:'fs',targets:[{format}]},primitive:{topology:'triangle-list'}});
557
+ const uniform = device.createBuffer({size:layout.size,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
558
+ let texture, computeGroup, presentGroup, frame = 0;
559
+ function resize() {
560
+ const width = Math.max(1, Math.round(canvas.clientWidth * devicePixelRatio));
561
+ const height = Math.max(1, Math.round(canvas.clientHeight * devicePixelRatio));
562
+ if (canvas.width === width && canvas.height === height && texture) return;
563
+ canvas.width = width; canvas.height = height; texture?.destroy();
564
+ texture = device.createTexture({size:[width,height],format:'rgba8unorm',usage:GPUTextureUsage.STORAGE_BINDING|GPUTextureUsage.TEXTURE_BINDING});
565
+ const view = texture.createView();
566
+ computeGroup = device.createBindGroup({layout:compute.getBindGroupLayout(0),entries:[{binding:0,resource:{buffer:uniform}},{binding:1,resource:view},...imageBindings]});
567
+ presentGroup = device.createBindGroup({layout:present.getBindGroupLayout(0),entries:[{binding:0,resource:view}]});
568
+ }
569
+ function draw(now) {
570
+ resize();
571
+ const data = new ArrayBuffer(layout.size), view = new DataView(data);
572
+ const put = (name, value) => { const field=layout.fields[name]; if(!field)return; const values=Array.isArray(value)?value:[value]; values.forEach((v,i)=>{const offset=field.offset+i*4; if(field.type==='int'||field.type==='bool')view.setInt32(offset,Number(v),true);else view.setFloat32(offset,Number(v),true);}); };
573
+ put('resolution',[canvas.width,canvas.height]); put('time',now/1000); put('frame',frame++); put('mouse',mouse);
574
+ for (const p of params) put(p.name,values[p.name]);
575
+ device.queue.writeBuffer(uniform,0,data);
576
+ const encoder=device.createCommandEncoder(), pass=encoder.beginComputePass();
577
+ pass.setPipeline(compute);pass.setBindGroup(0,computeGroup);pass.dispatchWorkgroups(Math.ceil(canvas.width/8),Math.ceil(canvas.height/8));pass.end();
578
+ const render=encoder.beginRenderPass({colorAttachments:[{view:context.getCurrentTexture().createView(),loadOp:'clear',storeOp:'store'}]});
579
+ render.setPipeline(present);render.setBindGroup(0,presentGroup);render.draw(3);render.end();
580
+ device.queue.submit([encoder.finish()]);requestAnimationFrame(draw);
581
+ }
582
+ status.textContent=''; requestAnimationFrame(draw);
583
+ }
584
+ start().catch(error => {status.textContent=error.message; console.error(error);});
585
+ </script>
586
+ HTML
587
+ File.write(path, html)
588
+ path
589
+ end
590
+ end
591
+ end
592
+
593
+ module_function
594
+
595
+ def shader(name, &block)
596
+ definition = Definition.new(name)
597
+ definition.instance_eval(&block) if block
598
+ definitions << definition
599
+ definition
600
+ end
601
+
602
+ def definitions
603
+ @definitions ||= []
604
+ end
605
+
606
+ def load_file(path)
607
+ definitions.clear
608
+ File.read(path)
609
+ load(path, true)
610
+ raise Error, "expected one Glaze.shader in #{path}, found #{definitions.length}" unless definitions.length == 1
611
+ definitions.first.tap { |definition| definition.instance_variable_set(:@file, path) }
612
+ rescue SyntaxError, StandardError => e
613
+ raise Error, "#{path}: #{e.message}"
614
+ end
615
+
616
+ def render(definition, width:, height:, time: 0.0, params: {})
617
+ image = Tessel::Image.new(width, height)
618
+ return image unless definition.fragment_block
619
+ uniforms = definition.params.to_h { |param| [param.name, params.fetch(param.name, param.default)] }
620
+ (0...height).each do |y|
621
+ (0...width).each do |x|
622
+ value = definition.fragment_block.call([x + 0.5, y + 0.5], [width, height], uniforms.merge(time: time, resolution: [width, height], frag_coord: [x + 0.5, y + 0.5]))
623
+ rgba = value.is_a?(Array) ? value : [value, value, value, 1.0]
624
+ rgba = rgba.first(4).map { |channel| [[(channel.to_f <= 1 ? channel.to_f * 255 : channel.to_f).round, 0].max, 255].min }
625
+ rgba << 255 if rgba.length == 3
626
+ image[x, y] = rgba
627
+ end
628
+ end
629
+ image
630
+ end
631
+
632
+ def export_webgpu(definition, path)
633
+ Export::WebGPU.write(definition, path)
634
+ end
635
+
636
+ def run_file(path, **options)
637
+ Runners::Window.new(path, **options).run
638
+ end
639
+
640
+ def capture(definition, path, time: 0.0, params: {}, size: [640, 360])
641
+ image = if definition.instance_variable_get(:@file)
642
+ Runners::CPU.new(definition).render(width: size[0], height: size[1], time: time, params: params)
643
+ else
644
+ render(definition, width: size[0], height: size[1], time: time, params: params)
645
+ end
646
+ save_capture(image, definition, path, params: params)
647
+ end
648
+
649
+ def save_capture(image, definition, path, params: {})
650
+ source = definition.instance_variable_get(:@file) && File.read(definition.instance_variable_get(:@file))
651
+ image.instance_variable_get(:@metadata).merge!("glaze:source" => source.to_s, "glaze:uniforms" => JSON.generate(params), "Software" => "glaze")
652
+ image.write(path)
653
+ end
654
+ end
data/sig/glaze.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Glaze
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: glaze
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ydah
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: tessel
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: rlsl
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.0.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 1.0.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: rbgl
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 1.0.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 1.0.0
54
+ description: Shader definitions, parameter metadata, CPU rendering, and WebGPU export.
55
+ email:
56
+ - t.yudai92@gmail.com
57
+ executables:
58
+ - glaze
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - CHANGELOG.md
63
+ - LICENSE.txt
64
+ - README.md
65
+ - Rakefile
66
+ - docs/spikes.md
67
+ - examples/gradient.rb
68
+ - examples/noise.png
69
+ - examples/plasma.rb
70
+ - examples/textured.rb
71
+ - exe/glaze
72
+ - lib/glaze.rb
73
+ - lib/glaze/clock.rb
74
+ - lib/glaze/dsl.rb
75
+ - lib/glaze/export/uniform_layout.rb
76
+ - lib/glaze/loader.rb
77
+ - lib/glaze/version.rb
78
+ - sig/glaze.rbs
79
+ homepage: https://github.com/rbgfx/glaze
80
+ licenses:
81
+ - MIT
82
+ metadata:
83
+ homepage_uri: https://github.com/rbgfx/glaze
84
+ source_code_uri: https://github.com/rbgfx/glaze/tree/main
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: 3.1.0
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 4.0.16
100
+ specification_version: 4
101
+ summary: Live coding tools for Ruby shaders
102
+ test_files: []