NEBULAKIT / OPEN SOURCE

Nebula Parallax Controller

A free MIT controller for three horizontal layers in Godot 4.5 Compatibility. Includes an original gradient, checker grid and stars demo; no Nebula pack artwork. Bring your own licensed textures. Code and documentation created with AI assistance.

Download standalone demo ZIP · Download import-safe addon ZIP

Open the standalone project's project.godot to try the controls. For an existing project, copy the addon ZIP's addons/nebula_backdrop/ folder. Tested locally in Godot 4.5 Compatibility; horizontal only. No accounts, external requests, analytics or customer data.

Actual Godot 4.5 controller demo showing original placeholder layers and motion controls

Optional artwork is separate: free Parallax pack; finished Godot kit ($1.49); Motion sampler / full video pack ($3). The controller does not play or import video loops.

LICENSE

Raw file
MIT License

Copyright (c) 2026 Omri Amdursky / NebulaKit

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

README.md

Raw file
# Nebula Parallax Controller — Free

A small MIT controller for three horizontal background layers. Bring your own licensed PNGs; use the original runtime-generated gradient/checker/stars demo to see how the layers move. **No Nebula pack artwork is included.**

Tested with **Godot 4.5, Compatibility renderer**. Horizontal scrolling only. AI assistance was used for code and documentation; runtime placeholder graphics are generated from the included code.

## Try it

Open `project.godot` in Godot 4.5 and press F6 on `addons/nebula_backdrop/demo/demo.tscn`, or F5. Change speed, camera mode/position, brightness, pause and reduced motion. These are real controller inputs. Reduced motion holds the current frame; it does not follow a system preference automatically.

## Install in your project

1. Copy `addons/nebula_backdrop/` into your project. No plugin needs enabling.
2. Put `backdrop.tscn` in a `CanvasLayer` behind gameplay, for example with layer `-10`.
3. Create a new `NebulaBackdropPreset` resource and assign **three textures with identical dimensions**. Foreground layers can have transparency. For smooth wrap, use horizontally tileable images. Save the resource and assign it to the backdrop's `preset` property.
4. Set `speed` in source-image pixels/second, or use camera input:

```gdscript
@onready var backdrop: NebulaBackdrop = $CanvasLayer/NebulaBackdrop

func _process(_delta: float) -> void:
    backdrop.motion_mode = NebulaBackdrop.MotionMode.CAMERA_INPUT
    # Pass horizontal position in source-image pixels, not screen pixels.
    backdrop.set_camera_position(Vector2($Camera2D.global_position.x, 0.0))
```

`layer_depths` defaults to `(0.2, 0.5, 1.0)`. `brightness` dims RGB while preserving the source alpha. `paused` and `reduced_motion` hold the visible frame. The draw area scales to cover the viewport without changing image proportions; some cropping can occur. There is no vertical/world-space camera system or automatic camera connection.

The class names and addon path match the existing Nebula Backdrop Kit. **Install one copy only**; do not place a second copy under another directory in the same project.

## Optional artwork and finished presets

- [Free Parallax artwork](https://nebulakit.itch.io/nebula-parallax-vol1) — separate artwork licence; downloaded art is not covered by this MIT licence.
- [Nebula Backdrop Godot kit, $1.49](https://nebulakit.itch.io/nebula-backdrop-godot) — finished presets and artwork are separate from this placeholder demo.
- [Motion pack and free sampler, $3 full pack](https://nebulakit.itch.io/nebula-motion-vol1) — rendered video loops for compatible video workflows; this controller does not play or import them.

The addon works without buying anything or using any external account, service or customer data. No external files are fetched by the demo.

## Licence and source

MIT, copyright 2026 Omri Amdursky / NebulaKit. Keep the included `LICENSE` with redistributed code. See `verification/source-provenance.json` for the two unchanged controller scripts adapted from the existing MIT kit and the new demo material. Placeholder generation code and its runtime output are included under MIT; existing Nebula pack PNGs and licences are not included.

project.godot

Raw file
; Free controller demo. Open with Godot 4.5 Compatibility.
config_version=5

[application]
config/name="Nebula Parallax Controller — Free"
run/main_scene="res://addons/nebula_backdrop/demo/demo.tscn"
config/features=PackedStringArray("4.5", "GL Compatibility")
config/description="A MIT horizontal parallax controller with original runtime placeholder layers."

[display]
window/size/viewport_width=1120
window/size/viewport_height=700
window/size/window_width_override=1120
window/size/window_height_override=700
window/stretch/mode="canvas_items"

[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
environment/defaults/default_clear_color=Color(0.018, 0.024, 0.04, 1)

.gitignore

Raw file
.godot/
*.log
release/
verification/

tests/test_controller.gd

Raw file
extends SceneTree

var checks := 0
var failures: Array[String] = []

func _initialize() -> void:
	call_deferred("_run")

func check(condition: bool, message: String) -> void:
	checks += 1
	if not condition:
		failures.append(message)
		push_error(message)

func near(actual: float, expected: float, message: String) -> void:
	check(absf(actual - expected) < 0.001, message)

func _run() -> void:
	var scene := load("res://addons/nebula_backdrop/backdrop.tscn") as PackedScene
	check(scene != null, "clean addon scene loads without paid preset references")
	var backdrop := scene.instantiate() as NebulaBackdrop
	root.add_child(backdrop)
	backdrop.set_process(false)
	var preset := NebulaPlaceholderLayers.create_preset()
	check(preset.is_valid(), "three runtime placeholder layers form valid preset")
	for texture in preset.textures():
		check(texture.get_size() == Vector2(512, 288), "placeholder dimensions match")
	check(preset.nebula_texture.get_image().get_pixel(1, 1).a < 1.0, "middle layer preserves transparency")
	check(preset.stars_texture.get_image().get_pixel(0, 0).a == 0.0, "foreground transparency preserved")
	backdrop.set_preset_resource(preset)
	backdrop.speed = 100
	backdrop._process(1.0)
	near(backdrop.layer_phase(0), 20, "far depth")
	near(backdrop.layer_phase(1), 50, "middle depth")
	near(backdrop.layer_phase(2), 100, "foreground depth")
	backdrop.paused = true
	backdrop._process(20)
	near(backdrop.layer_phase(2), 100, "pause holds visible phase")
	backdrop.paused = false
	backdrop.reduced_motion = true
	backdrop._process(20)
	near(backdrop.layer_phase(2), 100, "reduced motion holds visible phase")
	backdrop.reduced_motion = false
	backdrop.motion_mode = NebulaBackdrop.MotionMode.CAMERA_INPUT
	backdrop.set_camera_position(Vector2(-100, 999))
	backdrop._process(0.1)
	near(backdrop.layer_phase(2), 412, "negative camera wraps horizontally")
	backdrop.paused = true
	backdrop.set_camera_position(Vector2(200, 0))
	near(backdrop.layer_phase(2), 412, "camera input respects pause")
	backdrop.paused = false
	backdrop._process(0.1)
	near(backdrop.layer_phase(2), 200, "camera resumes at latest input")
	backdrop.brightness = 0.5
	backdrop.tint = Color(0.8, 0.6, 0.4, 0.2)
	near(backdrop.layer_modulate().r, 0.4, "brightness multiplies RGB")
	near(backdrop.layer_modulate().a, 1, "brightness does not replace texture alpha")
	for size in [Vector2(1120, 700), Vector2(2560, 1080), Vector2(640, 960), Vector2(320, 180)]:
		backdrop.view_size_override = size
		var geometry := backdrop.layout()
		var tile: Vector2 = geometry["tile_size"]
		var y: float = geometry["vertical_offset"]
		check(y <= 0 and y + tile.y >= size.y, "covers viewport height")
		for layer in range(3):
			var rects := backdrop.layer_rects(layer)
			check(rects[0].position.x <= 0 and rects[-1].end.x >= size.x, "covers viewport width")
			for index in range(1, rects.size()):
				near(rects[index - 1].end.x, rects[index].position.x, "adjacent tiles have no geometry gap")
	var demo := load("res://addons/nebula_backdrop/demo/demo.tscn") as PackedScene
	check(demo != null, "standalone demo scene loads")
	var demo_node := demo.instantiate()
	root.add_child(demo_node)
	await process_frame
	check(demo_node.backdrop.preset.is_valid(), "standalone demo creates usable placeholder preset")
	check(demo_node.camera_slider != null, "camera control constructed")
	demo_node.backdrop.set_process(false)
	demo_node.speed_slider.value = -120
	near(demo_node.backdrop.speed, -120, "visible speed slider changes actual controller")
	demo_node.brightness_slider.value = 0.35
	near(demo_node.backdrop.brightness, 0.35, "visible brightness slider changes actual controller")
	demo_node.mode_picker.select(1)
	demo_node.mode_picker.item_selected.emit(1)
	check(demo_node.backdrop.motion_mode == NebulaBackdrop.MotionMode.CAMERA_INPUT and demo_node.camera_slider.editable, "visible mode control enables camera input")
	demo_node.camera_slider.value = -100
	near(demo_node.backdrop.layer_phase(2), 412, "visible camera slider moves actual controller")
	demo_node.pause_toggle.button_pressed = true
	demo_node.camera_slider.value = 250
	near(demo_node.backdrop.layer_phase(2), 412, "visible pause control holds phase during camera changes")
	demo_node.pause_toggle.button_pressed = false
	demo_node.backdrop._process(0.1)
	near(demo_node.backdrop.layer_phase(2), 250, "unpausing visible control applies held camera position")
	demo_node.reduced_motion_toggle.button_pressed = true
	check(demo_node.backdrop.reduced_motion, "visible reduced-motion toggle changes actual controller")
	print("CONTROLLER_CHECKS total=", checks, " failures=", failures.size())
	quit(0 if failures.is_empty() else 1)

verification/source-provenance.json

Raw file
{
  "source": "Existing Nebula Backdrop Kit MIT code",
  "copyright": "2026 Omri Amdursky / NebulaKit",
  "copied_files": [
    {
      "path": "addons/nebula_backdrop/backdrop.gd",
      "copied_from": "products/nebula-backdrop-godot/addons/nebula_backdrop/backdrop.gd",
      "sha256": "705eea015051af47311e222adcb72038fe894bf4a60398cea114a854a87692e1",
      "copy": "byte_identical"
    },
    {
      "path": "addons/nebula_backdrop/preset.gd",
      "copied_from": "products/nebula-backdrop-godot/addons/nebula_backdrop/preset.gd",
      "sha256": "13aa616300b74edeb1b8e089146db2204d93bb3e8566393aa69556430b3a7579",
      "copy": "byte_identical"
    }
  ],
  "restricted_art_included": false,
  "new_demo_art": "Original runtime placeholder pixels generated by placeholder_layers.gd; no external PNGs",
  "ai_assistance": "Code and documentation created or adapted with AI assistance"
}

addons/nebula_backdrop/backdrop.gd

Raw file
class_name NebulaBackdrop
extends Node2D
## Draws original layers in viewport coordinates; put it in a CanvasLayer behind gameplay.
## Horizontal repeat only. Units for speed and camera position are source-image pixels.

enum MotionMode { AUTOSCROLL, CAMERA_INPUT }

@export var preset: NebulaBackdropPreset:
	set(value):
		preset = value
		queue_redraw()
@export var motion_mode: MotionMode = MotionMode.AUTOSCROLL:
	set(value):
		if value == MotionMode.AUTOSCROLL or value == MotionMode.CAMERA_INPUT:
			motion_mode = value
@export_range(-1000.0, 1000.0, 1.0) var speed: float = 44.0:
	set(value):
		if is_finite(value):
			speed = value
@export var layer_depths: Vector3 = Vector3(0.2, 0.5, 1.0):
	set(value):
		if not value.is_finite():
			return
		layer_depths = value.clamp(Vector3.ZERO, Vector3(4.0, 4.0, 4.0))
		queue_redraw()
## RGB multiplication only: alpha is always 1, preserving each source layer's alpha.
@export_color_no_alpha var tint: Color = Color.WHITE:
	set(value):
		if not is_finite(value.r) or not is_finite(value.g) or not is_finite(value.b):
			return
		tint = Color(clampf(value.r, 0.0, 1.0), clampf(value.g, 0.0, 1.0), clampf(value.b, 0.0, 1.0), 1.0)
		queue_redraw()
@export_range(0.0, 1.0, 0.01) var brightness: float = 0.75:
	set(value):
		if not is_finite(value):
			return
		brightness = clampf(value, 0.0, 1.0)
		queue_redraw()
@export var paused: bool = false
@export var reduced_motion: bool = false
## Optional draw area for embedding or deterministic testing; zero follows viewport.
@export var view_size_override: Vector2 = Vector2.ZERO:
	set(value):
		view_size_override = value
		queue_redraw()

var _travel: float = 0.0
var _camera_position: Vector2 = Vector2.ZERO
var _visible_camera_x: float = 0.0

func _ready() -> void:
	get_viewport().size_changed.connect(queue_redraw)
	texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
	queue_redraw()

func _process(delta: float) -> void:
	if paused or reduced_motion:
		return
	if motion_mode == MotionMode.AUTOSCROLL:
		_travel += speed * maxf(delta, 0.0)
	else:
		_visible_camera_x = _camera_position.x
	queue_redraw()

func set_camera_position(position_in_source_pixels: Vector2) -> void:
	if not is_finite(position_in_source_pixels.x):
		return
	_camera_position = Vector2(position_in_source_pixels.x, 0.0)
	if not paused and not reduced_motion:
		_visible_camera_x = _camera_position.x
		queue_redraw()

func reset_scroll() -> void:
	_travel = 0.0
	_camera_position = Vector2.ZERO
	_visible_camera_x = 0.0
	queue_redraw()

func set_preset_resource(value: NebulaBackdropPreset) -> void:
	preset = value
	reset_scroll()

## Shared by rendering and tests. Brightness dims RGB without changing texture alpha.
func layer_modulate() -> Color:
	return Color(tint.r * brightness, tint.g * brightness, tint.b * brightness, 1.0)

## Copy into a new scene script, or merge _ready() with your existing script.
## Transfers configuration and camera input, not elapsed animation/held frame state.
## Empty means the preset must first be saved as a standalone res:// resource.
func settings_snippet() -> String:
	if preset == null or not preset.is_valid():
		return ""
	var path := preset.resource_path
	if not path.begins_with("res://") or path.contains("::"):
		return ""
	var lines := PackedStringArray([
		"extends Node",
		"",
		"# Adjust this node path to match your scene.",
		"@onready var space: NebulaBackdrop = $CanvasLayer/NebulaBackdrop",
		"",
		"func _ready() -> void:",
		"\tspace.set_preset_resource(load(%s) as NebulaBackdropPreset)" % var_to_str(path),
		"\tspace.motion_mode = NebulaBackdrop.MotionMode.%s" % ("AUTOSCROLL" if motion_mode == MotionMode.AUTOSCROLL else "CAMERA_INPUT"),
		"\tspace.speed = %s" % _number_literal(speed),
		"\tspace.layer_depths = Vector3(%s, %s, %s)" % [_number_literal(layer_depths.x), _number_literal(layer_depths.y), _number_literal(layer_depths.z)],
		"\tspace.tint = Color(%s, %s, %s, 1.0)" % [_number_literal(tint.r), _number_literal(tint.g), _number_literal(tint.b)],
		"\tspace.brightness = %s" % _number_literal(brightness),
		# Set the position before the holds so a static camera preview is reproduced.
		"\tspace.paused = false",
		"\tspace.reduced_motion = false",
		"\tspace.set_camera_position(Vector2(%s, 0.0))" % _number_literal(_camera_position.x),
		"\tspace.paused = %s" % ("true" if paused else "false"),
		"\tspace.reduced_motion = %s" % ("true" if reduced_motion else "false"),
	])
	return "\n".join(lines) + "\n"

static func _number_literal(value: float) -> String:
	# Locale-independent decimals, retaining precision for editable colors and speeds.
	return String.num(value, 14)

func view_size() -> Vector2:
	if view_size_override.x > 0.0 and view_size_override.y > 0.0:
		return view_size_override
	return get_viewport_rect().size

## Actual draw geometry, also useful when positioning another layer against this view.
func layout() -> Dictionary:
	if preset == null or not preset.is_valid():
		return {}
	var source := preset.far_texture.get_size()
	var target := view_size()
	var cover_scale := maxf(target.x / source.x, target.y / source.y)
	var tile := source * cover_scale
	return {
		"source_size": source,
		"view_size": target,
		"scale": cover_scale,
		"tile_size": tile,
		"vertical_offset": (target.y - tile.y) * 0.5,
	}

## Positive offset in source pixels. Depth 0 is stationary; negative scroll wraps safely.
func layer_phase(layer_index: int) -> float:
	if preset == null or not preset.is_valid() or layer_index < 0 or layer_index > 2:
		return 0.0
	var position_x := _travel if motion_mode == MotionMode.AUTOSCROLL else _visible_camera_x
	return fposmod(position_x * layer_depths[layer_index], float(preset.far_texture.get_width()))

func layer_rects(layer_index: int) -> Array[Rect2]:
	var geometry := layout()
	var result: Array[Rect2] = []
	if geometry.is_empty():
		return result
	var tile: Vector2 = geometry["tile_size"]
	var target: Vector2 = geometry["view_size"]
	var x: float = -layer_phase(layer_index) * float(geometry["scale"])
	var y: float = geometry["vertical_offset"]
	# The first copy starts at or left of the view; repeat until the right edge is covered.
	while x < target.x:
		result.append(Rect2(Vector2(x, y), tile))
		x += tile.x
	return result

func _draw() -> void:
	draw_rect(Rect2(Vector2.ZERO, view_size()), Color(0.01, 0.018, 0.038))
	if preset == null or not preset.is_valid():
		return
	var layers := preset.textures()
	var color_multiplier := layer_modulate()
	for layer_index in range(3):
		for rect in layer_rects(layer_index):
			draw_texture_rect(layers[layer_index], rect, false, color_multiplier)

addons/nebula_backdrop/backdrop.tscn

Raw file
[gd_scene load_steps=2 format=3]

[ext_resource type="Script" path="res://addons/nebula_backdrop/backdrop.gd" id="1"]

[node name="NebulaBackdrop" type="Node2D"]
script = ExtResource("1")

addons/nebula_backdrop/demo/demo.gd

Raw file
extends Node

const BACKDROP_SCENE := preload("res://addons/nebula_backdrop/backdrop.tscn")
var backdrop: NebulaBackdrop
var panel: PanelContainer
var status: Label
var camera_slider: HSlider
var mode_picker: OptionButton
var speed_slider: HSlider
var brightness_slider: HSlider
var pause_toggle: CheckBox
var reduced_motion_toggle: CheckBox
var capture_path := ""

func _ready() -> void:
	var background := CanvasLayer.new()
	background.layer = -10
	add_child(background)
	backdrop = BACKDROP_SCENE.instantiate() as NebulaBackdrop
	background.add_child(backdrop)
	backdrop.set_preset_resource(NebulaPlaceholderLayers.create_preset())
	_build_controls()
	for argument in OS.get_cmdline_user_args():
		if argument.begins_with("--capture="):
			capture_path = argument.trim_prefix("--capture=")
	if not capture_path.is_empty():
		pause_toggle.button_pressed = true
		await get_tree().process_frame
		await RenderingServer.frame_post_draw
		var output := get_viewport().get_texture().get_image()
		var result := output.save_png(capture_path)
		print("DEMO_CAPTURE result=", result, " size=", output.get_size())
		get_tree().quit(0 if result == OK else 1)

func _process(_delta: float) -> void:
	if status != null:
		var mode := "autoscroll" if backdrop.motion_mode == NebulaBackdrop.MotionMode.AUTOSCROLL else "camera input"
		var hold := " / held" if backdrop.paused or backdrop.reduced_motion else ""
		status.text = "%s%s\nLayer offsets: %.1f / %.1f / %.1f px" % [mode, hold, backdrop.layer_phase(0), backdrop.layer_phase(1), backdrop.layer_phase(2)]

func _label(value: String, size: int = 15) -> Label:
	var label := Label.new()
	label.text = value
	label.add_theme_font_size_override("font_size", size)
	label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	return label

func _slider(parent: VBoxContainer, title: String, low: float, high: float, value: float, step: float, callback: Callable) -> HSlider:
	var label := _label(title + ": " + String.num(value, 2))
	parent.add_child(label)
	var slider := HSlider.new()
	slider.min_value = low
	slider.max_value = high
	slider.step = step
	slider.value = value
	slider.value_changed.connect(func(number: float) -> void:
		label.text = title + ": " + String.num(number, 2)
		callback.call(number)
	)
	parent.add_child(slider)
	return slider

func _build_controls() -> void:
	var layer := CanvasLayer.new()
	add_child(layer)
	panel = PanelContainer.new()
	panel.position = Vector2(24, 24)
	panel.custom_minimum_size = Vector2(342, 0)
	var style := StyleBoxFlat.new()
	style.bg_color = Color(0.02, 0.025, 0.035, 0.96)
	style.border_color = Color(0.21, 0.26, 0.31)
	style.set_border_width_all(1)
	style.content_margin_left = 20
	style.content_margin_right = 20
	style.content_margin_top = 18
	style.content_margin_bottom = 18
	panel.add_theme_stylebox_override("panel", style)
	layer.add_child(panel)
	var column := VBoxContainer.new()
	column.add_theme_constant_override("separation", 12)
	panel.add_child(column)
	column.add_child(_label("NEBULA / FREE CONTROLLER", 12))
	column.add_child(_label("Horizontal parallax", 24))
	column.add_child(_label("Three original placeholder layers:\ngradient / checker grid / stars."))
	var mode := OptionButton.new()
	mode_picker = mode
	mode.add_item("Autoscroll")
	mode.add_item("Camera input")
	mode.item_selected.connect(func(index: int) -> void:
		backdrop.motion_mode = index as NebulaBackdrop.MotionMode
		camera_slider.editable = index == 1
		if index == 1:
			backdrop.set_camera_position(Vector2(camera_slider.value, 0.0))
	)
	column.add_child(mode)
	speed_slider = _slider(column, "Speed / source px per second", -240, 240, backdrop.speed, 1, func(value: float) -> void: backdrop.speed = value)
	camera_slider = _slider(column, "Camera X / source pixels", -1024, 1024, 0, 1, func(value: float) -> void: backdrop.set_camera_position(Vector2(value, 0.0)))
	camera_slider.editable = false
	brightness_slider = _slider(column, "Brightness", 0, 1, backdrop.brightness, 0.05, func(value: float) -> void: backdrop.brightness = value)
	var pause := CheckBox.new()
	pause_toggle = pause
	pause.text = "Pause current frame"
	pause.toggled.connect(func(value: bool) -> void: backdrop.paused = value)
	column.add_child(pause)
	var reduced := CheckBox.new()
	reduced_motion_toggle = reduced
	reduced.text = "Reduced motion / hold frame"
	reduced.toggled.connect(func(value: bool) -> void: backdrop.reduced_motion = value)
	column.add_child(reduced)
	status = _label("", 13)
	column.add_child(status)
	column.add_child(_label("Replace placeholders with your own licensed layers. This demo includes no Nebula pack artwork.\nMIT code · Godot 4.5 Compatibility · AI-assisted code", 12))

addons/nebula_backdrop/demo/demo.tscn

Raw file
[gd_scene load_steps=2 format=3]

[ext_resource type="Script" path="res://addons/nebula_backdrop/demo/demo.gd" id="1"]

[node name="ParallaxControllerDemo" type="Node"]
script = ExtResource("1")

addons/nebula_backdrop/LICENSE

Raw file
MIT License

Copyright (c) 2026 Omri Amdursky / NebulaKit

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

addons/nebula_backdrop/placeholder_layers.gd

Raw file
class_name NebulaPlaceholderLayers
extends RefCounted
## Original instructional pixels, generated at runtime. No pack artwork or external files.

const SIZE := Vector2i(512, 288)

static func create_preset() -> NebulaBackdropPreset:
	var far := Image.create(SIZE.x, SIZE.y, false, Image.FORMAT_RGBA8)
	var grid := Image.create(SIZE.x, SIZE.y, false, Image.FORMAT_RGBA8)
	var stars := Image.create(SIZE.x, SIZE.y, false, Image.FORMAT_RGBA8)
	grid.fill(Color.TRANSPARENT)
	stars.fill(Color.TRANSPARENT)
	for y in range(SIZE.y):
		var height_mix := float(y) / float(SIZE.y - 1)
		for x in range(SIZE.x):
			var wave := (cos(TAU * float(x) / float(SIZE.x)) + 1.0) * 0.5
			far.set_pixel(x, y, Color(0.02 + 0.025 * wave, 0.045 + 0.025 * height_mix, 0.10 + 0.065 * wave, 1.0))
			if x % 32 == 0 or y % 32 == 0:
				grid.set_pixel(x, y, Color(0.16, 0.7, 0.78, 0.24))
			elif (x / 32 + y / 32) % 2 == 0:
				grid.set_pixel(x, y, Color(0.12, 0.3, 0.55, 0.08))
	var random := RandomNumberGenerator.new()
	random.seed = 20261001
	for index in range(100):
		var x := random.randi_range(2, SIZE.x - 3)
		var y := random.randi_range(2, SIZE.y - 3)
		var color := Color(0.65 + random.randf() * 0.3, 0.82, 1.0, 0.7 + random.randf() * 0.3)
		stars.set_pixel(x, y, color)
		if index % 8 == 0:
			stars.set_pixel(x - 1, y, Color(color, 0.45))
			stars.set_pixel(x + 1, y, Color(color, 0.45))
			stars.set_pixel(x, y - 1, Color(color, 0.45))
			stars.set_pixel(x, y + 1, Color(color, 0.45))
	var result := NebulaBackdropPreset.new()
	result.display_name = "Original gradient / checker / stars placeholders"
	result.far_texture = ImageTexture.create_from_image(far)
	result.nebula_texture = ImageTexture.create_from_image(grid)
	result.stars_texture = ImageTexture.create_from_image(stars)
	return result

addons/nebula_backdrop/preset.gd

Raw file
class_name NebulaBackdropPreset
extends Resource
## One immutable set of three original, horizontally repeating artwork layers.

@export var display_name: String = "Nebula"
@export var far_texture: Texture2D
@export var nebula_texture: Texture2D
@export var stars_texture: Texture2D

func textures() -> Array[Texture2D]:
	return [far_texture, nebula_texture, stars_texture]

func is_valid() -> bool:
	var layers := textures()
	for layer in layers:
		if layer == null or layer.get_width() <= 0 or layer.get_height() <= 0:
			return false
	return layers[0].get_size() == layers[1].get_size() and layers[0].get_size() == layers[2].get_size()

addons/nebula_backdrop/README.md

Raw file
# Nebula Parallax Controller — Free

A small MIT controller for three horizontal background layers. Bring your own licensed PNGs; use the original runtime-generated gradient/checker/stars demo to see how the layers move. **No Nebula pack artwork is included.**

Tested with **Godot 4.5, Compatibility renderer**. Horizontal scrolling only. AI assistance was used for code and documentation; runtime placeholder graphics are generated from the included code.

## Try it

Open `project.godot` in Godot 4.5 and press F6 on `addons/nebula_backdrop/demo/demo.tscn`, or F5. Change speed, camera mode/position, brightness, pause and reduced motion. These are real controller inputs. Reduced motion holds the current frame; it does not follow a system preference automatically.

## Install in your project

1. Copy `addons/nebula_backdrop/` into your project. No plugin needs enabling.
2. Put `backdrop.tscn` in a `CanvasLayer` behind gameplay, for example with layer `-10`.
3. Create a new `NebulaBackdropPreset` resource and assign **three textures with identical dimensions**. Foreground layers can have transparency. For smooth wrap, use horizontally tileable images. Save the resource and assign it to the backdrop's `preset` property.
4. Set `speed` in source-image pixels/second, or use camera input:

```gdscript
@onready var backdrop: NebulaBackdrop = $CanvasLayer/NebulaBackdrop

func _process(_delta: float) -> void:
    backdrop.motion_mode = NebulaBackdrop.MotionMode.CAMERA_INPUT
    # Pass horizontal position in source-image pixels, not screen pixels.
    backdrop.set_camera_position(Vector2($Camera2D.global_position.x, 0.0))
```

`layer_depths` defaults to `(0.2, 0.5, 1.0)`. `brightness` dims RGB while preserving the source alpha. `paused` and `reduced_motion` hold the visible frame. The draw area scales to cover the viewport without changing image proportions; some cropping can occur. There is no vertical/world-space camera system or automatic camera connection.

The class names and addon path match the existing Nebula Backdrop Kit. **Install one copy only**; do not place a second copy under another directory in the same project.

## Optional artwork and finished presets

- [Free Parallax artwork](https://nebulakit.itch.io/nebula-parallax-vol1) — separate artwork licence; downloaded art is not covered by this MIT licence.
- [Nebula Backdrop Godot kit, $1.49](https://nebulakit.itch.io/nebula-backdrop-godot) — finished presets and artwork are separate from this placeholder demo.
- [Motion pack and free sampler, $3 full pack](https://nebulakit.itch.io/nebula-motion-vol1) — rendered video loops for compatible video workflows; this controller does not play or import them.

The addon works without buying anything or using any external account, service or customer data. No external files are fetched by the demo.

## Licence and source

MIT, copyright 2026 Omri Amdursky / NebulaKit. Keep the included `LICENSE` with redistributed code. See `verification/source-provenance.json` for the two unchanged controller scripts adapted from the existing MIT kit and the new demo material. Placeholder generation code and its runtime output are included under MIT; existing Nebula pack PNGs and licences are not included.