diff --git a/.codex/INSTALL.md b/.codex/INSTALL.md index dd3b05d..620e6af 100644 --- a/.codex/INSTALL.md +++ b/.codex/INSTALL.md @@ -32,6 +32,8 @@ Enable MiniMax skills in Codex via native skill discovery. Just clone and symlin - **frontend-dev** — Frontend development with UI design, animations, AI-generated media assets - **fullstack-dev** — Full-stack backend architecture and frontend-backend integration - **android-native-dev** — Android native application development with Material Design 3 +- **ios-application-dev** — iOS application development with UIKit, SnapKit, and SwiftUI +- **shader-dev** — GLSL shader techniques for stunning visual effects (ShaderToy-compatible) ## Verify diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index fe68c82..4e27a02 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "minimax-skills", "displayName": "MiniMax Skills", - "description": "MiniMax AI skills library: frontend development, fullstack development, and Android native development", + "description": "MiniMax AI skills library: frontend development, fullstack development, Android native development, iOS application development, and shader development", "version": "1.0.0", "author": { "name": "MiniMax AI" @@ -9,7 +9,7 @@ "homepage": "https://github.com/MiniMax-AI/skills", "repository": "https://github.com/MiniMax-AI/skills", "license": "MIT", - "keywords": ["skills", "frontend", "fullstack", "android", "minimax"], + "keywords": ["skills", "frontend", "fullstack", "android", "ios", "shader", "minimax"], "logo": "assets/logo.png", "skills": "./skills/" } diff --git a/.gitignore b/.gitignore index dbf8603..247e47e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ Thumbs.db # Node (opencode plugin) node_modules/ + +# Claude Code +CLAUDE.md diff --git a/.opencode/INSTALL.md b/.opencode/INSTALL.md index 884978a..e3febcb 100644 --- a/.opencode/INSTALL.md +++ b/.opencode/INSTALL.md @@ -23,6 +23,8 @@ Verify by asking: "List available skills" - **frontend-dev** — Frontend development with UI design, animations, AI-generated media assets - **fullstack-dev** — Full-stack backend architecture and frontend-backend integration - **android-native-dev** — Android native application development with Material Design 3 +- **ios-application-dev** — iOS application development with UIKit, SnapKit, and SwiftUI +- **shader-dev** — GLSL shader techniques for stunning visual effects (ShaderToy-compatible) ## Usage diff --git a/README.md b/README.md index 9303387..0261c49 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > **Beta** — This project is under active development. Skills, APIs, and configuration formats may change without notice. We welcome feedback and contributions. -Development skills for AI coding agents. Plug into your favorite AI coding tool and get structured, production-quality guidance for frontend, fullstack, and Android development. +Development skills for AI coding agents. Plug into your favorite AI coding tool and get structured, production-quality guidance for frontend, fullstack, Android, iOS, and shader development. ## Skills @@ -13,6 +13,8 @@ Development skills for AI coding agents. Plug into your favorite AI coding tool | `frontend-dev` | Full-stack frontend development combining premium UI design, cinematic animations (Framer Motion, GSAP), AI-generated media assets via MiniMax API (image, video, audio, music, TTS), persuasive copywriting (AIDA framework), and generative art (p5.js, Three.js, Canvas). Tech stack: React / Next.js, Tailwind CSS. | | `fullstack-dev` | Full-stack backend architecture and frontend-backend integration. REST API design, auth flows (JWT, session, OAuth), real-time features (SSE, WebSocket), database integration (SQL / NoSQL), production hardening, and release checklist. Guided workflow: requirements → architecture → implementation. | | `android-native-dev` | Android native application development with Material Design 3. Kotlin / Jetpack Compose, adaptive layouts, Gradle configuration, accessibility (WCAG), build troubleshooting, performance optimization, and motion system. | +| `ios-application-dev` | iOS application development guide covering UIKit, SnapKit, and SwiftUI. Touch targets, safe areas, navigation patterns, Dynamic Type, Dark Mode, accessibility, collection views, and Apple HIG compliance. | +| `shader-dev` | Comprehensive GLSL shader techniques for creating stunning visual effects — ray marching, SDF modeling, fluid simulation, particle systems, procedural generation, lighting, post-processing, and more. ShaderToy-compatible. | ## Installation diff --git a/README_zh.md b/README_zh.md index 83d9042..f82e798 100644 --- a/README_zh.md +++ b/README_zh.md @@ -4,7 +4,7 @@ > **Beta** — 本项目正在积极开发中。技能内容、API 和配置格式可能会在不另行通知的情况下变更。欢迎反馈和贡献。 -面向 AI 编程工具的开发技能库。接入你常用的 AI 编程工具,获得结构化的、生产级质量的前端、全栈和 Android 开发指导。 +面向 AI 编程工具的开发技能库。接入你常用的 AI 编程工具,获得结构化的、生产级质量的前端、全栈、Android、iOS 和着色器开发指导。 ## 技能列表 @@ -13,6 +13,8 @@ | `frontend-dev` | 全栈前端开发,融合高级 UI 设计、电影级动画(Framer Motion、GSAP)、通过 MiniMax API 生成媒体资源(图片、视频、音频、音乐、TTS)、基于 AIDA 框架的说服力文案、生成艺术(p5.js、Three.js、Canvas)。技术栈:React / Next.js、Tailwind CSS。 | | `fullstack-dev` | 全栈后端架构与前后端集成。REST API 设计、认证流程(JWT、Session、OAuth)、实时功能(SSE、WebSocket)、数据库集成(SQL / NoSQL)、生产环境加固与发布清单。引导式工作流:需求收集 → 架构决策 → 实现。 | | `android-native-dev` | 基于 Material Design 3 的 Android 原生应用开发。Kotlin / Jetpack Compose、自适应布局、Gradle 配置、无障碍(WCAG)、构建问题排查、性能优化与动效系统。 | +| `ios-application-dev` | iOS 应用开发指南,涵盖 UIKit、SnapKit 和 SwiftUI。触控目标、安全区域、导航模式、Dynamic Type、深色模式、无障碍、集合视图,符合 Apple HIG 规范。 | +| `shader-dev` | 全面的 GLSL 着色器技术,用于创建惊艳的视觉效果 — 光线行进、SDF 建模、流体模拟、粒子系统、程序化生成、光照、后处理等。兼容 ShaderToy。 | ## 安装 diff --git a/skills/ios-application-dev/SKILL.md b/skills/ios-application-dev/SKILL.md new file mode 100644 index 0000000..7d9992b --- /dev/null +++ b/skills/ios-application-dev/SKILL.md @@ -0,0 +1,178 @@ +--- +name: ios-application-dev +description: | + iOS application development guide covering UIKit, SnapKit, and SwiftUI. Includes touch targets, safe areas, navigation patterns, Dynamic Type, Dark Mode, accessibility, collection views, common UI components, and SwiftUI design guidelines. For detailed references on specific topics, see the reference files. + Use when: developing iOS apps, implementing UI, reviewing iOS code, working with UIKit/SnapKit/SwiftUI layouts, building iPhone interfaces, Swift mobile development, Apple HIG compliance, iOS accessibility implementation. +license: MIT +metadata: + author: MiniMax-OpenSource + version: "1.0.0" + category: mobile + sources: + - Apple Human Interface Guidelines + - Apple Developer Documentation +--- + +# iOS Application Development Guide + +A practical guide for building iOS applications using UIKit, SnapKit, and SwiftUI. Focuses on proven patterns and Apple platform conventions. + +## Quick Reference + +### UIKit + +| Purpose | Component | +|---------|-----------| +| Main sections | `UITabBarController` | +| Drill-down | `UINavigationController` | +| Focused task | Sheet presentation | +| Critical choice | `UIAlertController` | +| Secondary actions | `UIContextMenuInteraction` | +| List content | `UICollectionView` + `DiffableDataSource` | +| Sectioned list | `DiffableDataSource` + `headerMode` | +| Grid layout | `UICollectionViewCompositionalLayout` | +| Search | `UISearchController` | +| Share | `UIActivityViewController` | +| Location (once) | `CLLocationButton` | +| Feedback | `UIImpactFeedbackGenerator` | +| Linear layout | `UIStackView` | +| Custom shapes | `CAShapeLayer` + `UIBezierPath` | +| Gradients | `CAGradientLayer` | +| Modern buttons | `UIButton.Configuration` | +| Dynamic text | `UIFontMetrics` + `preferredFont` | +| Dark mode | Semantic colors (`.systemBackground`, `.label`) | +| Permissions | Contextual request + `AVCaptureDevice` | +| Lifecycle | `UIApplication` notifications | + +### SwiftUI + +| Purpose | Component | +|---------|-----------| +| Main sections | `TabView` + `tabItem` | +| Drill-down | `NavigationStack` + `NavigationPath` | +| Focused task | `.sheet` + `presentationDetents` | +| Critical choice | `.alert` | +| Secondary actions | `.contextMenu` | +| List content | `List` + `.insetGrouped` | +| Search | `.searchable` | +| Share | `ShareLink` | +| Location (once) | `LocationButton` | +| Feedback | `UIImpactFeedbackGenerator` | +| Progress (known) | `ProgressView(value:total:)` | +| Progress (unknown) | `ProgressView()` | +| Dynamic text | `.font(.body)` semantic styles | +| Dark mode | `.primary`, `.secondary`, `Color(.systemBackground)` | +| Scene lifecycle | `@Environment(\.scenePhase)` | +| Reduce motion | `@Environment(\.accessibilityReduceMotion)` | +| Dynamic type | `@Environment(\.dynamicTypeSize)` | + +## Core Principles + +### Layout +- Touch targets >= 44pt +- Content within safe areas (SwiftUI respects by default, use `.ignoresSafeArea()` only for backgrounds) +- Use 8pt spacing increments (8, 16, 24, 32, 40, 48) +- Primary actions in thumb zone +- Support all screen sizes (iPhone SE 375pt to Pro Max 430pt) + +### Typography +- UIKit: `preferredFont(forTextStyle:)` + `adjustsFontForContentSizeCategory = true` +- SwiftUI: semantic text styles `.headline`, `.body`, `.caption` +- Custom fonts: `UIFontMetrics` / `Font.custom(_:size:relativeTo:)` +- Adapt layout at accessibility sizes (minimum 11pt) + +### Colors +- Use semantic system colors (`.systemBackground`, `.label`, `.primary`, `.secondary`) +- Asset catalog variants for custom colors (Any/Dark Appearance) +- No color-only information (pair with icons or text) +- Contrast ratio >= 4.5:1 for normal text, 3:1 for large text + +### Accessibility +- Labels on icon buttons (`.accessibilityLabel()`) +- Reduce motion respected (`@Environment(\.accessibilityReduceMotion)`) +- Logical reading order (`.accessibilitySortPriority()`) +- Support Bold Text, Increase Contrast preferences + +### Navigation +- Tab bar (3-5 sections) stays visible during navigation +- Back swipe works (never override system gestures) +- State preserved across tabs (`@SceneStorage`, `@State`) +- Never use hamburger menus + +### Privacy & Permissions +- Request permissions in context (not at launch) +- Custom explanation before system dialog +- Support Sign in with Apple +- Respect ATT denial + +## Checklist + +### Layout +- [ ] Touch targets >= 44pt +- [ ] Content within safe areas +- [ ] Primary actions in thumb zone (bottom half) +- [ ] Flexible widths for all screen sizes (SE to Pro Max) +- [ ] Spacing aligns to 8pt grid + +### Typography +- [ ] Semantic text styles or UIFontMetrics-scaled custom fonts +- [ ] Dynamic Type supported up to accessibility sizes +- [ ] Layouts reflow at large sizes (no truncation) +- [ ] Minimum text size 11pt + +### Colors +- [ ] Semantic system colors or light/dark asset variants +- [ ] Dark Mode is intentional (not just inverted) +- [ ] No color-only information +- [ ] Text contrast >= 4.5:1 (normal) / 3:1 (large) +- [ ] Single accent color for interactive elements + +### Accessibility +- [ ] VoiceOver labels on all interactive elements +- [ ] Logical reading order +- [ ] Bold Text preference respected +- [ ] Reduce Motion disables decorative animations +- [ ] All gestures have alternative access paths + +### Navigation +- [ ] Tab bar for 3-5 top-level sections +- [ ] No hamburger/drawer menus +- [ ] Tab bar stays visible during navigation +- [ ] Back swipe works throughout +- [ ] State preserved across tabs + +### Components +- [ ] Alerts for critical decisions only +- [ ] Sheets have dismiss path (button and/or swipe) +- [ ] List rows >= 44pt tall +- [ ] Destructive buttons use `.destructive` role + +### Privacy +- [ ] Permissions requested in context (not at launch) +- [ ] Custom explanation before system permission dialog +- [ ] Sign in with Apple offered with other providers +- [ ] Basic features usable without account +- [ ] ATT prompt shown if tracking, denial respected + +### System Integration +- [ ] App handles interruptions gracefully (calls, background, Siri) +- [ ] App content indexed for Spotlight +- [ ] Share Sheet available for shareable content + +## References + +| Topic | Reference | +|-------|-----------| +| Touch Targets, Safe Area, CollectionView | [Layout System](references/layout-system.md) | +| TabBar, NavigationController, Modal | [Navigation Patterns](references/navigation-patterns.md) | +| StackView, Button, Alert, Search, ContextMenu | [UIKit Components](references/uikit-components.md) | +| CAShapeLayer, CAGradientLayer, Core Animation | [Graphics & Animation](references/graphics-animation.md) | +| Dynamic Type, Semantic Colors, VoiceOver | [Accessibility](references/accessibility.md) | +| Permissions, Location, Share, Lifecycle, Haptics | [System Integration](references/system-integration.md) | +| Metal Shaders & GPU | [Metal Shader Reference](references/metal-shader.md) | +| SwiftUI HIG, Components, Patterns, Anti-Patterns | [SwiftUI Design Guidelines](references/swiftui-design-guidelines.md) | +| Optionals, Protocols, async/await, ARC, Error Handling | [Swift Coding Standards](references/swift-coding-standards.md) | + +--- + +Swift, SwiftUI, UIKit, SF Symbols, Metal, and Apple are trademarks of Apple Inc. SnapKit is a trademark of its respective owners. diff --git a/skills/ios-application-dev/references/accessibility.md b/skills/ios-application-dev/references/accessibility.md new file mode 100644 index 0000000..22ecab5 --- /dev/null +++ b/skills/ios-application-dev/references/accessibility.md @@ -0,0 +1,259 @@ +# Accessibility + +iOS accessibility guide covering Dynamic Type, semantic colors, VoiceOver, and motion adaptation. + +## Dynamic Type + +### Using System Fonts + +```swift +private func setupLabels() { + let titleLabel = UILabel() + titleLabel.font = .preferredFont(forTextStyle: .headline) + titleLabel.adjustsFontForContentSizeCategory = true + + let bodyLabel = UILabel() + bodyLabel.font = .preferredFont(forTextStyle: .body) + bodyLabel.adjustsFontForContentSizeCategory = true + bodyLabel.numberOfLines = 0 +} +``` + +### Custom Font Scaling + +```swift +extension UIFont { + static func scaled(_ name: String, size: CGFloat, for style: TextStyle) -> UIFont { + guard let font = UIFont(name: name, size: size) else { + return .preferredFont(forTextStyle: style) + } + return UIFontMetrics(forTextStyle: style).scaledFont(for: font) + } +} + +let customFont = UIFont.scaled("Avenir-Medium", size: 16, for: .body) +``` + +### Text Style Reference + +| Style | Default Size | Usage | +|-------|--------------|-------| +| `.largeTitle` | 34pt | Screen titles | +| `.title1` | 28pt | Primary headings | +| `.title2` | 22pt | Secondary headings | +| `.title3` | 20pt | Tertiary headings | +| `.headline` | 17pt (semibold) | Important information | +| `.body` | 17pt | Body text | +| `.callout` | 16pt | Explanatory text | +| `.subheadline` | 15pt | Subtitles | +| `.footnote` | 13pt | Footnotes | +| `.caption1` | 12pt | Labels | +| `.caption2` | 11pt | Small labels | + +### Adapting Layout for Large Text + +```swift +override func traitCollectionDidChange(_ previous: UITraitCollection?) { + super.traitCollectionDidChange(previous) + + let isLargeText = traitCollection.preferredContentSizeCategory.isAccessibilityCategory + contentStack.axis = isLargeText ? .vertical : .horizontal + + if isLargeText { + iconImageView.snp.remakeConstraints { make in + make.size.equalTo(64) + } + } else { + iconImageView.snp.remakeConstraints { make in + make.size.equalTo(44) + } + } +} +``` + +## Semantic Colors + +Use system semantic colors for automatic Dark Mode adaptation: + +```swift +view.backgroundColor = .systemBackground +containerView.backgroundColor = .secondarySystemBackground +cardView.backgroundColor = .tertiarySystemBackground + +titleLabel.textColor = .label +subtitleLabel.textColor = .secondaryLabel +hintLabel.textColor = .tertiaryLabel +placeholderLabel.textColor = .placeholderText + +separatorView.backgroundColor = .separator +borderView.layer.borderColor = UIColor.separator.cgColor +``` + +### System Color Reference + +| Color | Light Mode | Dark Mode | Usage | +|-------|------------|-----------|-------| +| `.systemBackground` | White | Black | Main background | +| `.secondarySystemBackground` | Light gray | Dark gray | Card/grouped background | +| `.tertiarySystemBackground` | Lighter gray | Medium gray | Nested content background | +| `.label` | Black | White | Primary text | +| `.secondaryLabel` | Gray | Light gray | Secondary text | +| `.tertiaryLabel` | Light gray | Dark gray | Auxiliary text | + +### Custom Color Adaptation + +```swift +extension UIColor { + static let customAccent = UIColor { traitCollection in + switch traitCollection.userInterfaceStyle { + case .dark: + return UIColor(red: 0.4, green: 0.8, blue: 1.0, alpha: 1.0) + default: + return UIColor(red: 0.0, green: 0.5, blue: 0.8, alpha: 1.0) + } + } +} +``` + +## VoiceOver + +### Basic Labels + +```swift +let cartButton = UIButton(type: .system) +cartButton.setImage(UIImage(systemName: "cart.badge.plus"), for: .normal) +cartButton.accessibilityLabel = "Add to cart" + +let ratingView = UIView() +ratingView.accessibilityLabel = "Rating: 4 out of 5 stars" + +let closeButton = UIButton() +closeButton.accessibilityLabel = "Close" +closeButton.accessibilityHint = "Dismisses this dialog" +``` + +### Custom Accessibility + +```swift +class ProductCell: UICollectionViewCell { + override var accessibilityLabel: String? { + get { + return "\(product.name), \(product.price), \(product.isAvailable ? "In stock" : "Out of stock")" + } + set {} + } + + override var accessibilityTraits: UIAccessibilityTraits { + get { + var traits: UIAccessibilityTraits = .button + if product.isSelected { + traits.insert(.selected) + } + return traits + } + set {} + } +} +``` + +### Accessibility Container + +```swift +class CustomContainerView: UIView { + override var isAccessibilityElement: Bool { + get { false } + set {} + } + + override var accessibilityElements: [Any]? { + get { + return [titleLabel, actionButton, detailLabel] + } + set {} + } +} +``` + +### VoiceOver Notifications + +```swift +func didLoadContent() { + UIAccessibility.post(notification: .screenChanged, argument: headerLabel) +} + +func didUpdateStatus() { + UIAccessibility.post(notification: .announcement, argument: "Download complete") +} +``` + +## Reduce Motion + +```swift +func animateTransition() { + let duration: TimeInterval = UIAccessibility.isReduceMotionEnabled ? 0 : 0.3 + UIView.animate(withDuration: duration) { + self.cardView.alpha = 1 + } +} + +func showPopup() { + if UIAccessibility.isReduceMotionEnabled { + popupView.alpha = 1 + } else { + popupView.transform = CGAffineTransform(scaleX: 0.8, y: 0.8) + popupView.alpha = 0 + UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0) { + self.popupView.transform = .identity + self.popupView.alpha = 1 + } + } +} +``` + +### Observing Setting Changes + +```swift +NotificationCenter.default.addObserver( + self, + selector: #selector(reduceMotionChanged), + name: UIAccessibility.reduceMotionStatusDidChangeNotification, + object: nil +) + +@objc func reduceMotionChanged() { + updateAnimationSettings() +} +``` + +## Accessibility Checklist + +### Basic Requirements +- [ ] All icon buttons have `accessibilityLabel` +- [ ] Custom controls have correct `accessibilityTraits` +- [ ] Images have `accessibilityLabel` or marked as decorative +- [ ] Forms have clear error messages + +### Dynamic Type +- [ ] Using `preferredFont(forTextStyle:)` +- [ ] Set `adjustsFontForContentSizeCategory = true` +- [ ] Layout adapts at accessibility sizes +- [ ] Text is not truncated + +### Color Contrast +- [ ] Body text contrast >= 4.5:1 +- [ ] Large text contrast >= 3:1 +- [ ] Information not conveyed by color alone + +### Motion +- [ ] Respect Reduce Motion setting +- [ ] No flashing or rapid animation +- [ ] Auto-playing animations can be paused + +### Interaction +- [ ] Touch targets >= 44x44pt +- [ ] Gestures have alternative actions +- [ ] Timeouts can be extended + +--- + +*UIKit, VoiceOver, Dynamic Type, and Apple are trademarks of Apple Inc.* diff --git a/skills/ios-application-dev/references/graphics-animation.md b/skills/ios-application-dev/references/graphics-animation.md new file mode 100644 index 0000000..12b660e --- /dev/null +++ b/skills/ios-application-dev/references/graphics-animation.md @@ -0,0 +1,350 @@ +# Graphics & Animation + +iOS graphics and animation guide covering CAShapeLayer, CAGradientLayer, UIBezierPath, and Core Animation. + +## CAShapeLayer + +For custom shapes, paths, and animations: + +```swift +class CircularProgressView: UIView { + private let trackLayer = CAShapeLayer() + private let progressLayer = CAShapeLayer() + + var progress: CGFloat = 0 { + didSet { updateProgress() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupLayers() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupLayers() + } + + private func setupLayers() { + let center = CGPoint(x: bounds.midX, y: bounds.midY) + let radius = min(bounds.width, bounds.height) / 2 - 10 + let startAngle = -CGFloat.pi / 2 + let endAngle = startAngle + 2 * CGFloat.pi + + let circularPath = UIBezierPath( + arcCenter: center, + radius: radius, + startAngle: startAngle, + endAngle: endAngle, + clockwise: true + ) + + trackLayer.path = circularPath.cgPath + trackLayer.strokeColor = UIColor.systemGray5.cgColor + trackLayer.fillColor = UIColor.clear.cgColor + trackLayer.lineWidth = 10 + trackLayer.lineCap = .round + layer.addSublayer(trackLayer) + + progressLayer.path = circularPath.cgPath + progressLayer.strokeColor = UIColor.systemBlue.cgColor + progressLayer.fillColor = UIColor.clear.cgColor + progressLayer.lineWidth = 10 + progressLayer.lineCap = .round + progressLayer.strokeEnd = 0 + layer.addSublayer(progressLayer) + } + + override func layoutSubviews() { + super.layoutSubviews() + setupLayers() + } + + private func updateProgress() { + progressLayer.strokeEnd = progress + } + + func animateProgress(to value: CGFloat, duration: TimeInterval = 0.5) { + let animation = CABasicAnimation(keyPath: "strokeEnd") + animation.fromValue = progressLayer.strokeEnd + animation.toValue = value + animation.duration = duration + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + progressLayer.strokeEnd = value + progressLayer.add(animation, forKey: "progressAnimation") + } +} +``` + +## UIBezierPath + +### Common Shapes + +```swift +let roundedRect = UIBezierPath( + roundedRect: bounds, + cornerRadius: 12 +) + +let customCorners = UIBezierPath( + roundedRect: bounds, + byRoundingCorners: [.topLeft, .topRight], + cornerRadii: CGSize(width: 16, height: 16) +) + +let triangle = UIBezierPath() +triangle.move(to: CGPoint(x: bounds.midX, y: 0)) +triangle.addLine(to: CGPoint(x: bounds.maxX, y: bounds.maxY)) +triangle.addLine(to: CGPoint(x: 0, y: bounds.maxY)) +triangle.close() + +let circle = UIBezierPath( + arcCenter: CGPoint(x: bounds.midX, y: bounds.midY), + radius: bounds.width / 2, + startAngle: 0, + endAngle: .pi * 2, + clockwise: true +) +``` + +### Custom Paths + +```swift +let customPath = UIBezierPath() +customPath.move(to: CGPoint(x: 0, y: bounds.height)) +customPath.addCurve( + to: CGPoint(x: bounds.width, y: 0), + controlPoint1: CGPoint(x: bounds.width * 0.3, y: bounds.height), + controlPoint2: CGPoint(x: bounds.width * 0.7, y: 0) +) +``` + +## CAGradientLayer + +### Linear Gradient Button + +```swift +class GradientButton: UIButton { + private let gradientLayer = CAGradientLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + setupGradient() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupGradient() + } + + private func setupGradient() { + gradientLayer.colors = [ + UIColor.systemBlue.cgColor, + UIColor.systemPurple.cgColor + ] + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + gradientLayer.cornerRadius = 12 + layer.insertSublayer(gradientLayer, at: 0) + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } +} +``` + +### Gradient Background View + +```swift +class GradientBackgroundView: UIView { + private let gradientLayer = CAGradientLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + setupGradient() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupGradient() + } + + private func setupGradient() { + gradientLayer.colors = [ + UIColor.systemBackground.cgColor, + UIColor.secondarySystemBackground.cgColor + ] + gradientLayer.locations = [0.0, 1.0] + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + layer.insertSublayer(gradientLayer, at: 0) + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + gradientLayer.colors = [ + UIColor.systemBackground.cgColor, + UIColor.secondarySystemBackground.cgColor + ] + } +} +``` + +### Gradient Types + +| Type | Configuration | +|------|---------------| +| Linear (horizontal) | `startPoint: (0, 0.5)`, `endPoint: (1, 0.5)` | +| Linear (vertical) | `startPoint: (0.5, 0)`, `endPoint: (0.5, 1)` | +| Diagonal | `startPoint: (0, 0)`, `endPoint: (1, 1)` | +| Radial | Use `CAGradientLayer.type = .radial` | + +## Core Animation + +### Basic Animation + +```swift +func animateScale() { + let animation = CABasicAnimation(keyPath: "transform.scale") + animation.fromValue = 1.0 + animation.toValue = 1.2 + animation.duration = 0.3 + animation.autoreverses = true + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + layer.add(animation, forKey: "scaleAnimation") +} + +func animatePosition() { + let animation = CABasicAnimation(keyPath: "position") + animation.fromValue = layer.position + animation.toValue = CGPoint(x: 200, y: 200) + animation.duration = 0.5 + layer.add(animation, forKey: "positionAnimation") +} +``` + +### Keyframe Animation + +```swift +func animateAlongPath() { + let path = UIBezierPath() + path.move(to: CGPoint(x: 50, y: 50)) + path.addCurve( + to: CGPoint(x: 250, y: 250), + controlPoint1: CGPoint(x: 150, y: 50), + controlPoint2: CGPoint(x: 50, y: 250) + ) + + let animation = CAKeyframeAnimation(keyPath: "position") + animation.path = path.cgPath + animation.duration = 2.0 + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + layer.add(animation, forKey: "pathAnimation") +} +``` + +### Animation Group + +```swift +func animateMultiple() { + let scaleAnimation = CABasicAnimation(keyPath: "transform.scale") + scaleAnimation.fromValue = 1.0 + scaleAnimation.toValue = 1.5 + + let opacityAnimation = CABasicAnimation(keyPath: "opacity") + opacityAnimation.fromValue = 1.0 + opacityAnimation.toValue = 0.0 + + let group = CAAnimationGroup() + group.animations = [scaleAnimation, opacityAnimation] + group.duration = 0.5 + group.fillMode = .forwards + group.isRemovedOnCompletion = false + + layer.add(group, forKey: "multipleAnimations") +} +``` + +### Spring Animation + +```swift +func springAnimation() { + let spring = CASpringAnimation(keyPath: "transform.scale") + spring.fromValue = 0.8 + spring.toValue = 1.0 + spring.damping = 10 + spring.stiffness = 100 + spring.mass = 1 + spring.initialVelocity = 5 + spring.duration = spring.settlingDuration + layer.add(spring, forKey: "springAnimation") +} +``` + +## UIView Animation + +### Basic UIView Animation + +```swift +UIView.animate(withDuration: 0.3) { + self.view.alpha = 1.0 + self.view.transform = .identity +} + +UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseInOut]) { + self.cardView.frame.origin.y = 100 +} completion: { _ in + self.didFinishAnimation() +} +``` + +### Spring Animation + +```swift +UIView.animate( + withDuration: 0.6, + delay: 0, + usingSpringWithDamping: 0.7, + initialSpringVelocity: 0.5, + options: [] +) { + self.popupView.transform = .identity +} +``` + +### Keyframe Animation + +```swift +UIView.animateKeyframes(withDuration: 1.0, delay: 0) { + UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.25) { + self.view.transform = CGAffineTransform(scaleX: 1.2, y: 1.2) + } + UIView.addKeyframe(withRelativeStartTime: 0.25, relativeDuration: 0.25) { + self.view.transform = CGAffineTransform(rotationAngle: .pi / 4) + } + UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) { + self.view.transform = .identity + } +} +``` + +## Timing Functions + +| Name | Description | +|------|-------------| +| `.linear` | Constant speed | +| `.easeIn` | Slow start | +| `.easeOut` | Slow end | +| `.easeInEaseOut` | Slow start and end | +| `.default` | System default | + +--- + +*UIKit, Core Animation, and Apple are trademarks of Apple Inc.* diff --git a/skills/ios-application-dev/references/layout-system.md b/skills/ios-application-dev/references/layout-system.md new file mode 100644 index 0000000..5269285 --- /dev/null +++ b/skills/ios-application-dev/references/layout-system.md @@ -0,0 +1,199 @@ +# Layout System + +iOS layout system guide covering touch targets, safe areas, UICollectionView, and Compositional Layout. + +## Touch Targets + +Interactive elements need adequate tap areas. The recommended minimum is 44x44 points. + +```swift +let actionButton = UIButton(type: .system) +actionButton.setTitle("Submit", for: .normal) +view.addSubview(actionButton) + +actionButton.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(44) + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) +} +``` + +Use 8-point increments for spacing (8, 16, 24, 32, 40, 48) to maintain visual consistency. + +## Safe Area + +Always constrain content to the safe area to avoid the notch, Dynamic Island, and home indicator. + +```swift +class MainViewController: UIViewController { + private let contentStack = UIStackView() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + contentStack.axis = .vertical + contentStack.spacing = 16 + view.addSubview(contentStack) + + contentStack.snp.makeConstraints { make in + make.top.bottom.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(16) + } + } +} +``` + +## UICollectionView with Diffable Data Source + +```swift +class ItemsViewController: UIViewController { + enum Section { case main } + + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + + override func viewDidLoad() { + super.viewDidLoad() + setupCollectionView() + configureDataSource() + } + + private func setupCollectionView() { + var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped) + config.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in + self?.makeSwipeActions(for: indexPath) + } + + let layout = UICollectionViewCompositionalLayout.list(using: config) + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + + view.addSubview(collectionView) + collectionView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func configureDataSource() { + let cellRegistration = UICollectionView.CellRegistration { + cell, indexPath, item in + var content = cell.defaultContentConfiguration() + content.text = item.title + content.secondaryText = item.subtitle + cell.contentConfiguration = content + } + + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { + collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, for: indexPath, item: item + ) + } + } + + func updateItems(_ items: [Item]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(items) + dataSource.apply(snapshot) + } +} +``` + +## Grid Layout + +```swift +private func createGridLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1/3), + heightDimension: .fractionalHeight(1.0) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.contentInsets = NSDirectionalEdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalWidth(1/3) + ) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + return UICollectionViewCompositionalLayout(section: section) +} +``` + +## Sectioned List with Headers + +```swift +class CategorizedListVC: UIViewController { + enum Section: Hashable { + case favorites, recent, all + } + + private var dataSource: UICollectionViewDiffableDataSource! + + private func setupCollectionView() { + var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped) + config.headerMode = .supplementary + + let layout = UICollectionViewCompositionalLayout.list(using: config) + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + } + + private func configureDataSource() { + let cellRegistration = UICollectionView.CellRegistration { + cell, indexPath, item in + var content = cell.defaultContentConfiguration() + content.text = item.title + cell.contentConfiguration = content + } + + let headerRegistration = UICollectionView.SupplementaryRegistration( + elementKind: UICollectionView.elementKindSectionHeader + ) { [weak self] header, elementKind, indexPath in + guard let section = self?.dataSource.sectionIdentifier(for: indexPath.section) else { return } + var content = header.defaultContentConfiguration() + content.text = self?.title(for: section) + header.contentConfiguration = content + } + + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { + collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) + } + + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath) + } + } + + func applySnapshot(favorites: [Item], recent: [Item], all: [Item]) { + var snapshot = NSDiffableDataSourceSnapshot() + if !favorites.isEmpty { + snapshot.appendSections([.favorites]) + snapshot.appendItems(favorites, toSection: .favorites) + } + if !recent.isEmpty { + snapshot.appendSections([.recent]) + snapshot.appendItems(recent, toSection: .recent) + } + snapshot.appendSections([.all]) + snapshot.appendItems(all, toSection: .all) + dataSource.apply(snapshot) + } +} +``` + +## Spacing Guidelines + +| Spacing | Usage | +|---------|-------| +| 8pt | Compact element spacing | +| 16pt | Standard padding | +| 24pt | Section spacing | +| 32pt | Large section separation | +| 48pt | Screen margins (large screens) | + +--- + +*UIKit and Apple are trademarks of Apple Inc. SnapKit is a trademark of its respective owners.* diff --git a/skills/ios-application-dev/references/metal-shader.md b/skills/ios-application-dev/references/metal-shader.md new file mode 100644 index 0000000..fecc5db --- /dev/null +++ b/skills/ios-application-dev/references/metal-shader.md @@ -0,0 +1,178 @@ +# Metal Shader Reference + +Expert reference for Metal shaders, real-time rendering, and Apple's Tile-Based Deferred Rendering (TBDR) architecture. + +## Core Principles + +**Half precision first → Leverage TBDR → Function constant specialization → Use Intersector API** + +### When to Use + +- Metal Shading Language (MSL) development +- Apple GPU optimization (TBDR architecture) +- PBR rendering pipelines +- Compute shaders and parallel processing +- Apple Silicon ray tracing +- GPU profiling and debugging + +### When NOT to Use + +- WebGL/GLSL (different architecture) +- CUDA (NVIDIA only) +- OpenGL (deprecated on Apple) +- CPU-side optimization + +## Expert vs Novice + +| Topic | Novice | Expert | +|-------|--------|--------| +| Data types | `float` everywhere | Default `half`, `float` only for position/depth | +| Branching | Runtime conditionals | Function constants for compile-time elimination | +| Memory | Everything in device | Know constant/device/threadgroup tradeoffs | +| Architecture | Treat as desktop GPU | Understand TBDR: tile memory is free, bandwidth is expensive | +| Ray tracing | intersection queries | intersector API (hardware-aligned) | +| Debugging | print debugging | GPU capture, shader profiler, occupancy analysis | + +## Common Anti-Patterns + +| Anti-Pattern | Problem | Solution | +|--------------|---------|----------| +| 32-bit floats | Wastes registers, reduces occupancy, doubles bandwidth | Default `half`, `float` only for position/depth | +| Ignoring TBDR | Not using free tile memory | Use `[[color(n)]]`, memoryless targets | +| Runtime constant branches | Warp divergence, wastes ALU | Function constants + pipeline specialization | +| intersection queries | Not hardware-aligned | Use intersector API | + +## Metal Evolution + +| Era | Key Development | +|-----|-----------------| +| Metal 2.x | OpenGL migration, basic compute | +| Apple Silicon | Unified memory, tile shaders critical | +| Metal 3 | Mesh shaders, hardware-accelerated ray tracing | +| Latest | Neural Engine + GPU cooperation, Vision Pro foveated rendering | + +**Apple Family 9 Note**: Threadgroup memory less advantageous vs direct device access. + +## Shader Types + +| Type | Purpose | Key Attributes | +|------|---------|----------------| +| Vertex | Vertex transformation | `[[stage_in]]`, `[[buffer(n)]]` | +| Fragment | Pixel shading | `[[color(n)]]`, `[[texture(n)]]` | +| Compute/Kernel | General computation | `[[thread_position_in_grid]]` | +| Tile | TBDR-specific | `[[imageblock]]` | +| Mesh | Metal 3 geometry | `[[mesh_id]]` | + +## Rendering Techniques + +| Technique | Description | +|-----------|-------------| +| Fullscreen quad | 4 vertex triangle strip, no MVP, post-processing basis | +| PBR Cook-Torrance | Fresnel Schlick + GGX Distribution + Smith Geometry | +| Blinn-Phong | Simple specular, half-vector calculation | + +## Procedural Generation + +| Technique | Use Case | +|-----------|----------| +| Hash functions | Pseudo-random basis for noise, random sampling | +| Voronoi | Cell textures, stones, cracks | +| Value/Perlin Noise | Continuous random fields | +| FBM | Multi-octave layering, fractal terrain, clouds | +| Domain Warping | Coordinate distortion, organic shapes | + +## Numerical Techniques + +| Technique | Formula | +|-----------|---------| +| Central difference gradient | `(f(x+h) - f(x-h)) / (2h)` | +| Smoothstep | `x * x * (3 - 2 * x)` | +| SDF operations | `min/max/smooth_min` boolean ops | + +## SwiftUI + MTKView Integration + +### Architecture Pattern + +``` +MetalView (UIViewRepresentable) + └── Coordinator = Renderer (MTKViewDelegate) + ├── MTLDevice + ├── MTLCommandQueue + ├── MTLRenderPipelineState + └── MTLBuffer (vertices, uniforms) +``` + +### Uniform Alignment Rules + +| Swift Type | Metal Type | Alignment | +|------------|------------|-----------| +| `Float` | `float` | 4 bytes | +| `SIMD2` | `float2` | 8 bytes | +| `SIMD3` | `float3` | **16 bytes** | +| `SIMD4` | `float4` | 16 bytes | + +**Key**: `float3` aligns to 16 bytes. Use `MemoryLayout.size` to verify. + +## Command Line Tools + +| Command | Purpose | +|---------|---------| +| `xcrun metal -c shader.metal -o shader.air` | Compile to AIR | +| `xcrun metallib shader.air -o shader.metallib` | Link to metallib | +| `xcrun metal shader.metal -o shader.metallib` | One-step compile & link | +| `xcrun metal -Weverything -c shader.metal` | Syntax check | +| `xcrun metal-objdump --disassemble shader.metallib` | Disassemble | + +## GPU Debugging + +### Xcode Workflow + +1. **GPU Capture**: ⌘⇧⌥G +2. **Shader Profiler**: Select draw call → View Shader +3. **Memory Viewer**: Inspect buffer/texture +4. **Performance HUD**: Enable in device options + +### Key Metrics + +| Metric | Healthy Value | Low Value Cause | +|--------|---------------|-----------------| +| GPU Occupancy | > 80% | Memory bandwidth bottleneck | +| ALU Utilization | > 60% | Waiting on memory | +| Bandwidth | As low as possible | TBDR should minimize store | + +### Debug Utility Functions + +| Function | Purpose | +|----------|---------| +| heatmap | Value visualization (blue→green→red) | +| debugNaN | NaN/Inf detection (magenta marker) | +| visualizeDepth | Linearized depth visualization | + +## Performance Optimization Checklist + +### Data Types +- [ ] Default `half`, `float` only for position/depth + +### Memory Management +- [ ] Constants in constant address space +- [ ] Use `.storageModeShared` +- [ ] Leverage tile memory (TBDR free reads) +- [ ] Avoid unnecessary render target stores + +### Branch Optimization +- [ ] Function constants to eliminate branches +- [ ] Fixed loop bounds (GPU unrolling) + +### Rendering Tips +- [ ] Fullscreen quad with 4 vertex triangle strip +- [ ] Procedural textures to avoid sampling bandwidth +- [ ] `[[early_fragment_tests]]` for early depth test +- [ ] `setFragmentBytes` for small data + +### Compute Optimization +- [ ] Vectorize (SIMD) +- [ ] Reduce register pressure + +--- + +*Metal, Apple Silicon, and Xcode are trademarks of Apple Inc.* diff --git a/skills/ios-application-dev/references/navigation-patterns.md b/skills/ios-application-dev/references/navigation-patterns.md new file mode 100644 index 0000000..5347a82 --- /dev/null +++ b/skills/ios-application-dev/references/navigation-patterns.md @@ -0,0 +1,175 @@ +# Navigation Patterns + +iOS navigation patterns guide covering Tab navigation, Navigation Controller, and modal presentation. + +## Tab-Based Navigation + +For apps with 3-5 main sections: + +```swift +class AppTabBarController: UITabBarController { + override func viewDidLoad() { + super.viewDidLoad() + + let homeNav = UINavigationController(rootViewController: HomeVC()) + homeNav.tabBarItem = UITabBarItem( + title: "Home", + image: UIImage(systemName: "house"), + selectedImage: UIImage(systemName: "house.fill") + ) + + let searchNav = UINavigationController(rootViewController: SearchVC()) + searchNav.tabBarItem = UITabBarItem( + title: "Search", + image: UIImage(systemName: "magnifyingglass"), + tag: 1 + ) + + let profileNav = UINavigationController(rootViewController: ProfileVC()) + profileNav.tabBarItem = UITabBarItem( + title: "Profile", + image: UIImage(systemName: "person"), + selectedImage: UIImage(systemName: "person.fill") + ) + + viewControllers = [homeNav, searchNav, profileNav] + } +} +``` + +### Tab Bar Best Practices + +| Principle | Description | +|-----------|-------------| +| Limit count | Maximum 5 tabs, use More for additional | +| Always visible | Tab bar stays visible at all navigation levels | +| State preservation | Preserve navigation state when switching tabs | +| Icon choice | Use SF Symbols, provide selected/unselected states | + +## Navigation Controller + +Use large titles for root views: + +```swift +class ListViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + title = "Items" + navigationController?.navigationBar.prefersLargeTitles = true + navigationItem.largeTitleDisplayMode = .always + } + + func pushDetail(_ item: Item) { + let detail = DetailViewController(item: item) + detail.navigationItem.largeTitleDisplayMode = .never + navigationController?.pushViewController(detail, animated: true) + } +} +``` + +### Navigation Bar Configuration + +```swift +class CustomNavigationController: UINavigationController { + override func viewDidLoad() { + super.viewDidLoad() + + let appearance = UINavigationBarAppearance() + appearance.configureWithDefaultBackground() + + navigationBar.standardAppearance = appearance + navigationBar.scrollEdgeAppearance = appearance + navigationBar.compactAppearance = appearance + } +} +``` + +### Navigation Bar Buttons + +```swift +override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "plus"), + style: .plain, + target: self, + action: #selector(addItem) + ) + + navigationItem.rightBarButtonItems = [ + UIBarButtonItem(systemItem: .add, primaryAction: UIAction { _ in }), + UIBarButtonItem(systemItem: .edit, primaryAction: UIAction { _ in }) + ] +} +``` + +## Modal Presentation + +### Sheet Presentation + +```swift +func presentEditor() { + let editorVC = EditorViewController() + let nav = UINavigationController(rootViewController: editorVC) + + editorVC.navigationItem.leftBarButtonItem = UIBarButtonItem( + systemItem: .cancel, target: self, action: #selector(dismissEditor) + ) + editorVC.navigationItem.rightBarButtonItem = UIBarButtonItem( + systemItem: .done, target: self, action: #selector(saveAndDismiss) + ) + + if let sheet = nav.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + } + + present(nav, animated: true) +} +``` + +### Custom Detent (iOS 16+) + +```swift +if let sheet = nav.sheetPresentationController { + let customDetent = UISheetPresentationController.Detent.custom { context in + return context.maximumDetentValue * 0.4 + } + sheet.detents = [customDetent, .large()] +} +``` + +### Full Screen Presentation + +```swift +func presentFullScreen() { + let vc = FullScreenViewController() + vc.modalPresentationStyle = .fullScreen + vc.modalTransitionStyle = .coverVertical + present(vc, animated: true) +} +``` + +## Presentation Styles + +| Style | Usage | +|-------|-------| +| `.automatic` | System default (usually sheet) | +| `.pageSheet` | Card-style, parent view visible | +| `.fullScreen` | Full screen cover | +| `.overFullScreen` | Full screen with transparent background | +| `.popover` | iPad popover | + +## Navigation Best Practices + +1. **Back gesture** - Ensure edge swipe back always works +2. **State restoration** - Use `UIStateRestoring` to save navigation stack +3. **Depth limit** - Avoid more than 4-5 navigation levels +4. **Cancel button** - Modal views must provide a cancel option +5. **Save confirmation** - Show confirmation dialog for unsaved changes + +--- + +*UIKit, SF Symbols, and Apple are trademarks of Apple Inc.* diff --git a/skills/ios-application-dev/references/swift-coding-standards.md b/skills/ios-application-dev/references/swift-coding-standards.md new file mode 100644 index 0000000..bf70237 --- /dev/null +++ b/skills/ios-application-dev/references/swift-coding-standards.md @@ -0,0 +1,741 @@ +# Swift Coding Standards + +Best practices for writing clean, safe, and idiomatic Swift code following Apple's guidelines and modern Swift conventions. + +--- + +## 1. Optionals and Safety + +**Impact:** CRITICAL + +Swift's optional system eliminates null pointer exceptions through compile-time safety. + +### 1.1 Safe Unwrapping with if let + +```swift +if let name = optionalName { + print("Hello, \(name)") +} + +// Multiple bindings +if let name = userName, let age = userAge, age >= 18 { + print("\(name) is an adult") +} +``` + +### 1.2 Guard for Early Exit + +Use `guard` to exit early when preconditions aren't met: + +```swift +func processUser(_ user: User?) { + guard let user = user else { return } + guard !user.name.isEmpty else { return } + print(user.name) +} +``` + +### 1.3 Nil Coalescing for Defaults + +```swift +let displayName = name ?? "Anonymous" +let count = items?.count ?? 0 +``` + +### 1.4 Optional Chaining + +```swift +let count = user?.profile?.posts?.count +let uppercased = optionalString?.uppercased() +``` + +### 1.5 Optional map/flatMap + +```swift +let uppercasedName = userName.map { $0.uppercased() } +let userID = userIDString.flatMap { Int($0) } +``` + +### 1.6 Never Force Unwrap + +Avoid `!` force unwrapping. Use safe alternatives: + +| Instead of | Use | +|------------|-----| +| `value!` | `if let value = value { }` | +| `array[0]` (unsafe) | `array.first` | +| `dictionary["key"]!` | `dictionary["key", default: defaultValue]` | + +--- + +## 2. Naming Conventions + +**Impact:** HIGH + +### 2.1 Types: PascalCase + +```swift +class UserProfileViewController { } +struct NetworkRequest { } +protocol DataSource { } +enum LoadingState { } +``` + +### 2.2 Variables and Functions: camelCase + +```swift +var userName: String +let maximumRetryCount = 3 +func fetchUserProfile() { } +``` + +### 2.3 Boolean Naming + +Use `is`, `has`, `should`, `can` prefixes: + +```swift +var isLoading: Bool +var hasCompletedOnboarding: Bool +var shouldShowAlert: Bool +var canEditProfile: Bool +``` + +### 2.4 Function Naming + +Use verb phrases, read like natural English: + +```swift +// Good - clear actions +func fetchUsers() async throws -> [User] +func remove(_ item: Item, at index: Int) +func makeIterator() -> Iterator + +// Avoid - unclear or redundant +func getUsersData() // "get" is redundant +func doRemove() // vague +``` + +### 2.5 Parameter Labels + +First parameter label can be omitted when obvious: + +```swift +func insert(_ element: Element, at index: Int) +func move(from source: Int, to destination: Int) +``` + +--- + +## 3. Protocol-Oriented Design + +**Impact:** HIGH + +Swift favors composition over inheritance through protocols. + +### 3.1 Define Capabilities Through Protocols + +```swift +protocol DataStore { + func save(_ item: T, key: String) throws + func load(key: String) throws -> T? +} + +protocol Drawable { + var color: Color { get set } + func draw() +} +``` + +### 3.2 Protocol Extensions for Default Behavior + +```swift +extension Drawable { + func draw() { + print("Drawing with \(color)") + } +} + +extension Collection { + func chunked(into size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { + Array(self[$0.. [Item] + func save(_ item: Item) async throws +} + +class UserRepository: Repository { + typealias Item = User + + func fetchAll() async throws -> [User] { /* ... */ } + func save(_ item: User) async throws { /* ... */ } +} +``` + +### 3.4 Protocol Composition + +```swift +protocol Named { var name: String { get } } +protocol Aged { var age: Int { get } } + +func greet(_ person: Named & Aged) { + print("Hello, \(person.name), age \(person.age)") +} +``` + +--- + +## 4. Value Types vs Reference Types + +**Impact:** HIGH + +### 4.1 Prefer Structs (Value Types) + +Use structs for simple data models, independent copies: + +```swift +struct User { + var name: String + var email: String +} + +struct Point { + var x: Double + var y: Double +} +``` + +### 4.2 Use Classes When Needed + +Use classes for shared mutable state, identity matters: + +```swift +class NetworkManager { + static let shared = NetworkManager() + private init() { } +} + +class FileHandle { + // Wrapping system resource +} +``` + +### 4.3 Enums for Finite States + +```swift +enum LoadingState { + case idle + case loading + case success(Data) + case failure(Error) +} + +enum Result { + case success(Success) + case failure(Failure) +} +``` + +| Type | Use When | +|------|----------| +| `struct` | Data models, coordinates, independent values | +| `class` | Shared state, identity matters, inheritance needed | +| `enum` | Finite set of options, state machines | + +--- + +## 5. Memory Management with ARC + +**Impact:** CRITICAL + +### 5.1 Breaking Retain Cycles with weak + +```swift +class Apartment { + weak var tenant: Person? +} + +class Person { + var apartment: Apartment? +} +``` + +### 5.2 Closure Capture Lists + +```swift +// Weak capture for optional self +onComplete = { [weak self] in + self?.processResult() +} + +// Capture specific values +let id = user.id +fetchData { [id] result in + print("Fetched for \(id)") +} +``` + +### 5.3 unowned for Guaranteed Lifetime + +Use when reference should never be nil during object lifetime: + +```swift +class CreditCard { + unowned let customer: Customer + + init(customer: Customer) { + self.customer = customer + } +} +``` + +| Keyword | Use When | +|---------|----------| +| `weak` | Reference may become nil | +| `unowned` | Reference guaranteed to outlive | +| None | Strong ownership needed | + +--- + +## 6. Error Handling + +**Impact:** HIGH + +### 6.1 Define Typed Errors + +```swift +enum NetworkError: Error { + case invalidURL + case noConnection + case serverError(statusCode: Int) + case decodingFailed(underlying: Error) +} + +enum ValidationError: LocalizedError { + case emptyField(name: String) + case invalidFormat(field: String, expected: String) + + var errorDescription: String? { + switch self { + case .emptyField(let name): + return "\(name) cannot be empty" + case .invalidFormat(let field, let expected): + return "\(field) must be \(expected)" + } + } +} +``` + +### 6.2 Throwing Functions + +```swift +func fetchUser(id: Int) throws -> User { + guard let url = URL(string: "https://api.example.com/users/\(id)") else { + throw NetworkError.invalidURL + } + // ... implementation +} +``` + +### 6.3 Do-Catch Handling + +```swift +do { + let user = try fetchUser(id: 123) + print(user.name) +} catch NetworkError.serverError(let code) { + print("Server error: \(code)") +} catch NetworkError.noConnection { + print("Check your internet connection") +} catch { + print("Unknown error: \(error)") +} +``` + +### 6.4 try? and try! + +```swift +// try? returns optional (nil on error) +let user = try? fetchUser(id: 123) + +// try! crashes on error - use only when failure is programmer error +let config = try! loadBundledConfig() +``` + +### 6.5 Rethrows + +```swift +func perform(_ operation: () throws -> T) rethrows -> T { + return try operation() +} +``` + +--- + +## 7. Modern Concurrency (async/await) + +**Impact:** CRITICAL + +### 7.1 Async Functions + +```swift +func fetchUser(id: Int) async throws -> User { + guard let url = URL(string: "https://api.example.com/users/\(id)") else { + throw NetworkError.invalidURL + } + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode(User.self, from: data) +} + +// Calling async functions +Task { + do { + let user = try await fetchUser(id: 123) + print(user.name) + } catch { + print("Failed: \(error)") + } +} +``` + +### 7.2 Parallel Execution with TaskGroup + +```swift +func fetchAllUsers(ids: [Int]) async throws -> [User] { + try await withThrowingTaskGroup(of: User.self) { group in + for id in ids { + group.addTask { + try await fetchUser(id: id) + } + } + return try await group.reduce(into: []) { $0.append($1) } + } +} +``` + +### 7.3 async let for Concurrent Bindings + +```swift +async let user = fetchUser(id: 1) +async let posts = fetchPosts(userId: 1) +async let followers = fetchFollowers(userId: 1) + +let profile = try await ProfileData( + user: user, + posts: posts, + followers: followers +) +``` + +### 7.4 Actors for Thread-Safe State + +```swift +actor BankAccount { + private var balance: Double = 0 + + func deposit(_ amount: Double) { + balance += amount + } + + func withdraw(_ amount: Double) throws { + guard balance >= amount else { + throw BankError.insufficientFunds + } + balance -= amount + } + + func getBalance() -> Double { + balance + } +} + +// Usage +let account = BankAccount() +await account.deposit(100) +let balance = await account.getBalance() +``` + +### 7.5 MainActor for UI Updates + +```swift +@MainActor +class ViewModel: ObservableObject { + @Published var isLoading = false + @Published var users: [User] = [] + + func loadUsers() async { + isLoading = true + defer { isLoading = false } + + do { + users = try await fetchUsers() + } catch { + // Handle error + } + } +} +``` + +### 7.6 Task Cancellation + +```swift +func fetchWithTimeout() async throws -> Data { + try await withThrowingTaskGroup(of: Data.self) { group in + group.addTask { + try await fetchData() + } + group.addTask { + try await Task.sleep(for: .seconds(10)) + throw TimeoutError() + } + + let result = try await group.next()! + group.cancelAll() + return result + } +} + +// Check for cancellation +func longOperation() async throws { + for item in items { + try Task.checkCancellation() + await process(item) + } +} +``` + +--- + +## 8. Access Control + +**Impact:** MEDIUM + +### 8.1 Access Levels + +| Level | Scope | +|-------|-------| +| `private` | Enclosing declaration only | +| `fileprivate` | Entire source file | +| `internal` | Module (default) | +| `public` | Other modules can access | +| `open` | Other modules can subclass/override | + +### 8.2 Best Practices + +```swift +public class UserService { + // Public API + public func fetchUser(id: Int) async throws -> User { } + + // Internal helper + func buildRequest(for id: Int) -> URLRequest { } + + // Private implementation detail + private let session: URLSession + private var cache: [Int: User] = [:] +} +``` + +### 8.3 Private Setters + +```swift +public struct Counter { + public private(set) var count = 0 + + public mutating func increment() { + count += 1 + } +} +``` + +--- + +## 9. Generics and Type Constraints + +**Impact:** MEDIUM + +### 9.1 Generic Functions + +```swift +func swapValues(_ a: inout T, _ b: inout T) { + let temp = a + a = b + b = temp +} +``` + +### 9.2 Type Constraints + +```swift +func findIndex(of value: T, in array: [T]) -> Int? { + array.firstIndex(of: value) +} + +func decode(_ type: T.Type, from data: Data) throws -> T { + try JSONDecoder().decode(type, from: data) +} +``` + +### 9.3 Where Clauses + +```swift +func allMatch(_ collection: C, predicate: (C.Element) -> Bool) -> Bool + where C.Element: Equatable { + collection.allSatisfy(predicate) +} + +extension Array where Element: Numeric { + func sum() -> Element { + reduce(0, +) + } +} +``` + +### 9.4 Opaque Types (some) + +```swift +func makeCollection() -> some Collection { + [1, 2, 3] +} + +var body: some View { + Text("Hello") +} +``` + +--- + +## 10. Property Wrappers + +**Impact:** MEDIUM + +### 10.1 Common SwiftUI Property Wrappers + +| Wrapper | Use Case | +|---------|----------| +| `@State` | View-local mutable state | +| `@Binding` | Two-way connection to parent state | +| `@StateObject` | View-owned observable object | +| `@ObservedObject` | Passed-in observable object | +| `@EnvironmentObject` | Shared object from ancestor | +| `@Environment` | System environment values | +| `@Published` | Observable property in class | + +### 10.2 Custom Property Wrappers + +```swift +@propertyWrapper +struct Clamped { + private var value: Value + let range: ClosedRange + + var wrappedValue: Value { + get { value } + set { value = min(max(newValue, range.lowerBound), range.upperBound) } + } + + init(wrappedValue: Value, _ range: ClosedRange) { + self.range = range + self.value = min(max(wrappedValue, range.lowerBound), range.upperBound) + } +} + +struct Settings { + @Clamped(0...100) var volume: Int = 50 +} +``` + +--- + +## Quick Reference + +### Optionals + +```swift +if let x = optional { } // Safe unwrap +guard let x = optional else { return } // Early exit +let x = optional ?? default // Default value +optional?.method() // Optional chaining +optional.map { transform($0) } // Transform if present +``` + +### Common Patterns + +```swift +// Defer for cleanup +func process() { + let file = openFile() + defer { closeFile(file) } + // ... work with file +} + +// Lazy initialization +lazy var expensive: ExpensiveObject = { + ExpensiveObject() +}() + +// Type inference +let numbers = [1, 2, 3] // [Int] +let doubled = numbers.map { $0 * 2 } // [Int] +``` + +### Closure Syntax + +```swift +// Full syntax +let sorted = names.sorted(by: { (s1: String, s2: String) -> Bool in + return s1 < s2 +}) + +// Shortened +let sorted = names.sorted { $0 < $1 } + +// Trailing closure +UIView.animate(withDuration: 0.3) { + view.alpha = 0 +} +``` + +--- + +## Checklist + +### Safety +- [ ] No force unwrapping (`!`) except for IB outlets and known-safe cases +- [ ] All optionals handled with `if let`, `guard let`, or `??` +- [ ] No implicitly unwrapped optionals (`!`) in data models + +### Memory +- [ ] Closures use `[weak self]` when capturing self in escaping closures +- [ ] Delegate properties are `weak` +- [ ] No retain cycles between objects + +### Concurrency +- [ ] Async functions used instead of completion handlers +- [ ] Actors protect shared mutable state +- [ ] UI updates on `@MainActor` +- [ ] Task cancellation checked in long operations + +### Access Control +- [ ] `private` used for implementation details +- [ ] `public` API is minimal and intentional +- [ ] No unnecessary `internal` exposure + +### Naming +- [ ] Types use PascalCase +- [ ] Functions and variables use camelCase +- [ ] Booleans have `is`/`has`/`should` prefix +- [ ] Functions read like natural English + +--- + +*Swift and Apple are trademarks of Apple Inc.* diff --git a/skills/ios-application-dev/references/swiftui-design-guidelines.md b/skills/ios-application-dev/references/swiftui-design-guidelines.md new file mode 100644 index 0000000..bcbfe1a --- /dev/null +++ b/skills/ios-application-dev/references/swiftui-design-guidelines.md @@ -0,0 +1,1167 @@ +# SwiftUI Design Guidelines + +Design rules based on Apple Human Interface Guidelines for building native iOS interfaces with SwiftUI. + +--- + +## Design Philosophy + +iOS design prioritizes **content over chrome**. The interface should feel invisible—users focus on their tasks, not the UI. + +**Key mindsets:** + +1. **Let content breathe** — Use full-screen layouts, minimize borders and boxes, let images and text take center stage + +2. **Leverage system conventions** — Users already know how iOS works; don't reinvent navigation, gestures, or controls + +3. **Design for fingers** — Touch is imprecise; generous tap targets and forgiving gesture recognition matter more than pixel-perfect layouts + +4. **Respect user choices** — Honor Dynamic Type, Dark Mode, Reduce Motion, and other accessibility settings as first-class requirements + +**iOS 26+ Liquid Glass:** +The latest iOS introduces translucent UI elements that respond to lighting and content behind them. Typography is bolder, text tends left-aligned for easier scanning. + +--- + +## 1. Layout & Safe Areas + +**Impact:** CRITICAL + +### 1.1 Minimum 44pt Touch Targets + +All interactive elements must have minimum 44x44 **points** (not pixels—points scale with screen density). + +```swift +Button(action: handleTap) { + Image(systemName: "heart.fill") +} +.frame(minWidth: 44, minHeight: 44) +``` + +Avoid placing critical interactions near screen edges where system gestures operate. + +### 1.2 Respect Safe Areas + +Never place interactive or essential content under the status bar, Dynamic Island, or home indicator. SwiftUI respects safe areas by default. Use `.ignoresSafeArea()` only for background fills, images, or decorative elements—never for text or interactive controls. + +```swift +ZStack { + LinearGradient(colors: [.blue, .purple], startPoint: .top, endPoint: .bottom) + .ignoresSafeArea() + + VStack { + Text("Welcome") + .font(.largeTitle) + Button("Get Started") { } + } +} +``` + +### 1.3 Primary Actions in Thumb Zone + +Place primary actions at the bottom of the screen where the user's thumb naturally rests. Secondary actions and navigation belong at the top. + +```swift +VStack { + ScrollView { + // Content + } + + Spacer() + + Button("Submit") { submit() } + .buttonStyle(.borderedProminent) + .padding(.horizontal) + .padding(.bottom) +} +``` + +### 1.4 Support All Screen Sizes + +Design for iPhone SE (375pt) through iPad Pro (1024pt+). Use Size Classes to adapt: + +```swift +@Environment(\.horizontalSizeClass) private var sizeClass + +var body: some View { + if sizeClass == .compact { + VStack { content } + } else { + HStack { content } + } +} +``` + +| Size Class | Devices | +|------------|---------| +| Compact width | iPhone portrait, small iPhone landscape | +| Regular width | iPad, large iPhone landscape | + +Use flexible layouts, avoid hardcoded widths: + +```swift +HStack(spacing: 16) { + ForEach(categories) { category in + CategoryCard(category: category) + .frame(maxWidth: .infinity) + } +} +``` + +### 1.5 8pt Grid Alignment + +Align spacing, padding, and element sizes to multiples of 8 points (8, 16, 24, 32, 40, 48). Use 4pt for fine adjustments. + +### 1.6 Landscape Support + +Support landscape orientation unless the app is task-specific (e.g., camera). Use `ViewThatFits` or `GeometryReader` for adaptive layouts. + +```swift +ViewThatFits { + HStack { contentViews } + VStack { contentViews } +} +``` + +--- + +## 2. Navigation + +**Impact:** CRITICAL + +### 2.1 Tab Bar for Top-Level Sections + +Use a tab bar at the bottom of the screen for 3 to 5 top-level sections. Each tab should represent a distinct category of content or functionality. + +```swift +TabView(selection: $selectedTab) { + HomeView() + .tabItem { + Label("Home", systemImage: "house") + } + .tag(Tab.home) + + DiscoverView() + .tabItem { + Label("Discover", systemImage: "magnifyingglass") + } + .tag(Tab.discover) + + AccountView() + .tabItem { + Label("Account", systemImage: "person") + } + .tag(Tab.account) +} +``` + +### 2.2 Navigation Architecture + +**Tab Bar (Flat)** — For 3-5 equal-importance sections +- Always visible except when covered by modals +- Each tab maintains its own navigation stack +- Most important content leftmost (easier thumb access) + +**Hierarchical (Drill-Down)** — For tree-structured info +- Push/pop navigation with back button +- Minimize depth (3-4 levels max) +- Provide search as escape hatch for deep trees + +**Modal (Focused Tasks)** — For self-contained workflows +- Full-screen for critical tasks +- Page sheet for dismissible tasks (swipe-down) +- Clear Done/Cancel with confirmation if data loss possible + +Never use hamburger menus—they reduce feature discoverability significantly. + +### 2.3 Large Titles in Primary Views + +Use `.navigationBarTitleDisplayMode(.large)` for top-level views. Titles transition to inline when the user scrolls. + +```swift +NavigationStack { + List(conversations) { conversation in + ConversationRow(conversation: conversation) + } + .navigationTitle("Inbox") + .navigationBarTitleDisplayMode(.large) +} +``` + +### 2.4 Never Override Back Swipe + +The swipe-from-left-edge gesture for back navigation is a system-level expectation. Never attach custom gesture recognizers that interfere with it. + +### 2.5 Use NavigationStack for Hierarchical Content + +Use `NavigationStack` (not the deprecated `NavigationView`) for drill-down content. Use `NavigationPath` for programmatic navigation. + +```swift +@State private var navPath = NavigationPath() + +NavigationStack(path: $navPath) { + List(products) { product in + NavigationLink(value: product) { + ProductRow(product: product) + } + } + .navigationDestination(for: Product.self) { product in + ProductDetailView(product: product) + } +} +``` + +### 2.6 Preserve State Across Navigation + +When users navigate back and then forward, or switch tabs, restore the previous scroll position and input state. + +```swift +@SceneStorage("selectedTab") private var selectedTab = Tab.home +@SceneStorage("scrollPosition") private var scrollPosition: String? +``` + +--- + +## 3. Typography & Dynamic Type + +**Impact:** HIGH + +### 3.1 Use Built-in Text Styles + +Always use semantic text styles—they scale with Dynamic Type automatically: + +| Style | Usage | +|-------|-------| +| `.largeTitle` | Screen titles | +| `.title`, `.title2`, `.title3` | Section headers | +| `.headline` | Emphasized body text | +| `.body` | Primary content (17pt default) | +| `.callout` | Secondary emphasized | +| `.subheadline` | Supporting labels | +| `.footnote`, `.caption` | Tertiary info | +| `.caption2` | Minimum size (11pt) | + +```swift +VStack(alignment: .leading, spacing: 8) { + Text("Article Title") + .font(.headline) + + Text("Published by Author Name") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(articleBody) + .font(.body) +} +``` + +### 3.2 Support Dynamic Type Including Accessibility Sizes + +Dynamic Type can scale text up to approximately 200% at the largest accessibility sizes. Layouts must reflow—never truncate or clip essential text. + +```swift +@Environment(\.dynamicTypeSize) private var typeSize + +var body: some View { + if typeSize.isAccessibilitySize { + VStack(alignment: .leading) { content } + } else { + HStack { content } + } +} +``` + +### 3.3 Custom Fonts Must Scale + +If you use a custom typeface, scale it with `Font.custom(_:size:relativeTo:)` so it responds to Dynamic Type. + +```swift +Text("Brand Text") + .font(.custom("Avenir-Medium", size: 17, relativeTo: .body)) +``` + +### 3.4 SF Pro as System Font + +Use the system font (SF Pro) unless brand requirements dictate otherwise. SF Pro is optimized for legibility on Apple displays. + +### 3.5 Minimum 11pt Text + +Never display text smaller than 11pt. Prefer 17pt for body text. Use the `caption2` style (11pt) as the absolute minimum. + +### 3.6 Hierarchy Through Weight and Size + +Establish visual hierarchy through font weight and size. Do not rely solely on color to differentiate text levels. + +### 3.7 SF Symbols + +Use SF Symbols (6,900+ icons) instead of custom image assets: + +```swift +// Basic usage with automatic text alignment +Label("Favorites", systemImage: "star.fill") + +// Rendering modes +Image(systemName: "cloud.sun.rain") + .symbolRenderingMode(.hierarchical) // or .multicolor, .palette + .imageScale(.large) // .small, .medium, .large +``` + +SF Symbols automatically match text weight, scale with Dynamic Type, and align to text baselines. Let them size naturally—don't force them into fixed-dimension containers. + +--- + +## 4. Color & Dark Mode + +**Impact:** HIGH + +### 4.1 Use Semantic System Colors + +Never use hard-coded RGB, hex, or `.black`/`.white` directly. Use semantic colors: + +**Labels:** +- `.primary`, `.secondary`, `.tertiary`, `.quaternary` + +**Backgrounds:** +- `Color(.systemBackground)` — primary surface +- `Color(.secondarySystemBackground)` — cards, grouped +- `Color(.tertiarySystemBackground)` — nested elements + +**System Colors (adapt to appearance):** +- `.blue`, `.red`, `.green`, `.orange`, `.yellow`, `.purple`, `.pink`, `.cyan`, `.mint`, `.teal`, `.indigo`, `.brown`, `.gray` + +```swift +VStack { + Text("Primary content") + .foregroundStyle(.primary) + + Text("Supporting info") + .foregroundStyle(.secondary) +} +.background(Color(.systemBackground)) +``` + +### 4.2 Custom Colors Need 4 Variants + +For custom colors, define in asset catalog with all appearance combinations: +1. Light mode +2. Dark mode +3. Light mode + High Contrast +4. Dark mode + High Contrast + +```swift +Text("Branded element") + .foregroundStyle(Color("AccentBrand")) +``` + +For dynamic colors in code: + +```swift +let dynamicColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0.9, green: 0.9, blue: 1.0, alpha: 1.0) + : UIColor(red: 0.1, green: 0.1, blue: 0.2, alpha: 1.0) +} +``` + +### 4.3 Never Rely on Color Alone + +Always pair color with text, icons, or shapes to convey meaning. Approximately 8% of men have some form of color vision deficiency. + +```swift +HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + Text("Connection failed") +} +.foregroundStyle(.red) +``` + +### 4.4 4.5:1 Contrast Ratio Minimum + +All text must meet WCAG AA contrast ratios: 4.5:1 for normal text, 3:1 for large text (18pt+ or 14pt+ bold). + +### 4.5 Support Display P3 Wide Gamut + +Use Display P3 color space for vibrant, accurate colors on modern iPhones. Define colors in the asset catalog with the Display P3 gamut. + +### 4.6 Background Hierarchy + +Layer backgrounds to create visual depth: + +```swift +// Level 1: Main view background +Color(.systemBackground) + +// Level 2: Cards, grouped sections +Color(.secondarySystemBackground) + +// Level 3: Nested elements within cards +Color(.tertiarySystemBackground) +``` + +### 4.7 One Accent Color for Interactive Elements + +Choose a single tint/accent color for all interactive elements (buttons, links, toggles). This creates a consistent, learnable visual language. + +```swift +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .tint(.orange) + } + } +} +``` + +--- + +## 5. Accessibility + +**Impact:** CRITICAL + +### 5.1 VoiceOver Labels on All Interactive Elements + +Every button, control, and interactive element must have a meaningful accessibility label. + +```swift +Button(action: toggleFavorite) { + Image(systemName: isFavorite ? "heart.fill" : "heart") +} +.accessibilityLabel(isFavorite ? "Remove from favorites" : "Add to favorites") +``` + +### 5.2 Logical VoiceOver Navigation Order + +Ensure VoiceOver reads elements in a logical order. Use `.accessibilitySortPriority()` to adjust when the visual layout doesn't match the reading order. + +```swift +HStack { + Text("$49.99") + .accessibilitySortPriority(2) + Text("Premium Plan") + .accessibilitySortPriority(1) +} +``` + +### 5.3 Support Bold Text + +When the user enables Bold Text in Settings, SwiftUI text styles handle this automatically. Custom text must respond to `UIAccessibility.isBoldTextEnabled`. + +### 5.4 Support Reduce Motion + +Disable decorative animations and parallax when Reduce Motion is enabled. + +```swift +@Environment(\.accessibilityReduceMotion) private var reduceMotion + +var body: some View { + CardView() + .animation(reduceMotion ? nil : .spring(duration: 0.4), value: expanded) +} +``` + +### 5.5 Support Increase Contrast + +When the user enables Increase Contrast, ensure custom colors have higher-contrast variants. Use `@Environment(\.colorSchemeContrast)` to detect. + +### 5.6 Don't Convey Info Only by Color, Shape, or Position + +Information must be available through multiple channels. Pair visual indicators with text or accessibility descriptions. + +### 5.7 Alternative Interactions for All Gestures + +Every custom gesture must have an equivalent tap-based or menu-based alternative for users who cannot perform complex gestures. + +### 5.8 Support Switch Control and Full Keyboard Access + +Ensure all interactions work with Switch Control (external switches) and Full Keyboard Access (Bluetooth keyboards). Test navigation order and focus behavior. + +--- + +## 6. Gestures & Input + +**Impact:** HIGH + +### 6.1 Use Standard Gestures + +Stick to gestures users already know: + +- **Tap** — Select items, trigger buttons +- **Long press** — Show context menus, enter edit mode +- **Horizontal swipe** — List row actions (delete/archive), back navigation +- **Vertical swipe** — Scroll content, dismiss sheets +- **Pinch** — Scale images/maps +- **Rotate** — Adjust angle (photos, maps) + +### 6.2 Never Override System Gestures + +iOS reserves these edge gestures—do not intercept: + +- Left edge swipe → back navigation +- Top-left pull → Notification Center +- Top-right pull → Control Center +- Bottom edge swipe → home/app switcher + +### 6.3 Custom Gestures Must Be Discoverable + +If you add a custom gesture, provide visual hints (e.g., a grabber handle) and ensure the action is also available through a visible button or menu item. + +### 6.4 Support All Input Methods + +Design for touch first, but also support hardware keyboards, assistive devices (Switch Control, head tracking), and pointer input. + +--- + +## 7. Components + +**Impact:** HIGH + +### 7.1 Button Styles + +Use the built-in button styles appropriately: + +```swift +VStack(spacing: 16) { + Button("Checkout") { checkout() } + .buttonStyle(.borderedProminent) + + Button("Add to Wishlist") { addToWishlist() } + .buttonStyle(.bordered) + + Button("Remove Item", role: .destructive) { removeItem() } +} +``` + +### 7.2 Alerts — Critical Info Only + +Use alerts sparingly for critical information that requires a decision. Prefer 2 buttons; maximum 3. + +```swift +.alert("Discard Draft?", isPresented: $showDiscardAlert) { + Button("Discard", role: .destructive) { discardDraft() } + Button("Keep Editing", role: .cancel) { } +} message: { + Text("Your unsaved changes will be lost.") +} +``` + +### 7.3 Sheets for Scoped Tasks + +Present sheets for self-contained tasks. Always provide a way to dismiss (close button or swipe down). + +```swift +.sheet(isPresented: $showEditor) { + NavigationStack { + EditorView() + .navigationTitle("Edit Profile") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showEditor = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { saveProfile() } + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) +} +``` + +### 7.4 Lists — The Foundation of iOS Apps + +Most iOS apps are lists ("90% of mobile design is list design"). + +**List Styles:** +- `.insetGrouped` — Modern default (rounded corners, margins) +- `.grouped` — Traditional grouped sections +- `.plain` — Edge-to-edge rows +- `.sidebar` — Three-column iPad layout + +**Swipe Actions:** +- Leading swipe → Positive actions (mark read, archive) +- Trailing swipe → Destructive actions (delete at far right) +- Maximum 3-4 actions per side + +**Row Accessories:** +- Chevron → Indicates navigation +- Checkmark → Shows selection +- Detail button → Additional info without navigation + +```swift +List { + Section("Notifications") { + ForEach(notifications) { notification in + NotificationRow(notification: notification) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + delete(notification) + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + markRead(notification) + } label: { + Label("Read", systemImage: "envelope.open") + } + .tint(.blue) + } + .swipeActions(edge: .leading) { + Button { + pin(notification) + } label: { + Label("Pin", systemImage: "pin") + } + .tint(.orange) + } + } + } +} +.listStyle(.insetGrouped) +``` + +### 7.5 Tab Bar Behavior + +- Use SF Symbols for tab icons — filled variant for the selected tab, outline for unselected +- Never hide the tab bar when navigating deeper within a tab +- Badge important counts with `.badge()` + +```swift +NotificationsView() + .tabItem { + Label("Notifications", systemImage: "bell") + } + .badge(unreadCount) +``` + +### 7.6 Search + +Place search using `.searchable()`. Provide search suggestions and support recent searches. + +```swift +NavigationStack { + List(searchResults) { item in + ItemRow(item: item) + } + .searchable(text: $query, prompt: "Search products") + .searchSuggestions { + ForEach(recentSearches, id: \.self) { term in + Text(term) + .searchCompletion(term) + } + } +} +``` + +### 7.7 Context Menus + +Use context menus (long press) for secondary actions. Never use a context menu as the only way to access an action. + +```swift +ImageThumbnail(image: image) + .contextMenu { + Button { shareImage(image) } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + Button { copyImage(image) } label: { + Label("Copy", systemImage: "doc.on.doc") + } + Divider() + Button(role: .destructive) { deleteImage(image) } label: { + Label("Delete", systemImage: "trash") + } + } +``` + +### 7.8 Forms and Input + +**Text Fields:** +- 44pt minimum height +- Match keyboard type to input (`.emailAddress`, `.numberPad`, `.URL`) +- Clear button when text entered +- Placeholder uses `.quaternary` label color + +```swift +Form { + Section("Account") { + TextField("Email", text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocapitalization(.none) + + SecureField("Password", text: $password) + .textContentType(.password) + } + + Section { + Button("Sign In") { signIn() } + .disabled(email.isEmpty || password.isEmpty) + } +} +``` + +**Pickers:** +- Inline → 3-7 options +- Menu → 2-5 options (iOS 14+) +- Wheel → Date/time or long lists + +### 7.9 Progress Indicators + +- Determinate `ProgressView(value:total:)` for operations with known duration +- Indeterminate `ProgressView()` for unknown duration +- Never block the entire screen with a spinner + +```swift +VStack { + ProgressView(value: uploadProgress, total: 1.0) + .progressViewStyle(.linear) + + Text("\(Int(uploadProgress * 100))% uploaded") + .font(.caption) + .foregroundStyle(.secondary) +} +``` + +--- + +## 8. Patterns + +**Impact:** MEDIUM + +### 8.1 Onboarding — Max 3 Pages, Skippable + +Keep onboarding to 3 or fewer pages. Always provide a skip option. Defer sign-in until the user needs authenticated features. + +```swift +TabView(selection: $currentPage) { + OnboardingPage(icon: "sparkles", title: "Smart Features", description: "...") + .tag(0) + OnboardingPage(icon: "bell.badge", title: "Stay Notified", description: "...") + .tag(1) + OnboardingPage(icon: "lock.shield", title: "Private & Secure", description: "...") + .tag(2) +} +.tabViewStyle(.page) +.overlay(alignment: .topTrailing) { + Button("Skip") { finishOnboarding() } + .padding() +} +``` + +### 8.2 Loading — Skeleton Views, No Blocking Spinners + +Use skeleton/placeholder views that match the layout of the content being loaded. Never show a full-screen blocking spinner. + +```swift +if isLoading { + ForEach(0..<5, id: \.self) { _ in + ArticleRowPlaceholder() + .redacted(reason: .placeholder) + } +} else { + ForEach(articles) { article in + ArticleRow(article: article) + } +} +``` + +### 8.3 Launch Screen — Match First Screen + +The launch storyboard must visually match the initial screen of the app. No splash logos, no branding screens. This creates the perception of instant launch. + +### 8.4 Modality — Use Sparingly + +Present modal views only when the user must complete or abandon a focused task. Always provide a clear dismiss action. Never stack modals on top of modals. + +### 8.5 Notifications — High Value Only + +Only send notifications for content the user genuinely cares about. Support actionable notifications. Categorize notifications so users can control them granularly. + +### 8.6 Settings Placement + +- Frequent settings: In-app settings screen accessible from a profile or gear icon +- Privacy/permission settings: Defer to the system Settings app via URL scheme +- Never duplicate system-level controls in-app + +### 8.7 Action Sheets + +For destructive or multiple-choice actions: + +```swift +.confirmationDialog("Delete Photo?", isPresented: $showDelete, titleVisibility: .visible) { + Button("Delete", role: .destructive) { deletePhoto() } + Button("Cancel", role: .cancel) { } +} message: { + Text("This action cannot be undone.") +} +``` + +- Destructive action at top (red) +- Cancel at bottom +- Dismiss by tapping outside + +### 8.8 Pull-to-Refresh + +Standard pattern for content updates: + +```swift +List(items) { item in + ItemRow(item: item) +} +.refreshable { + await loadNewItems() +} +``` + +### 8.9 Haptic Feedback + +Provide tactile response for significant actions: + +| Generator | Usage | +|-----------|-------| +| `UIImpactFeedbackGenerator` | Physical impacts (.light, .medium, .heavy) | +| `UINotificationFeedbackGenerator` | Success, warning, error | +| `UISelectionFeedbackGenerator` | Selection changes | + +```swift +Button("Complete") { + let feedback = UINotificationFeedbackGenerator() + feedback.notificationOccurred(.success) + markComplete() +} +``` + +--- + +## 9. Privacy & Permissions + +**Impact:** HIGH + +### 9.1 Request Permissions in Context + +Request a permission at the moment the user takes an action that needs it—never at app launch. + +```swift +Button("Take Photo") { + AVCaptureDevice.requestAccess(for: .video) { granted in + if granted { + showCamera = true + } + } +} +``` + +### 9.2 Explain Before System Prompt + +Show a custom explanation screen before triggering the system permission dialog. The system dialog only appears once—if the user denies, the app must direct them to Settings. + +```swift +struct LocationPermissionView: View { + var body: some View { + VStack(spacing: 20) { + Image(systemName: "location.fill") + .font(.system(size: 48)) + .foregroundStyle(.blue) + + Text("Find Nearby Places") + .font(.title2.bold()) + + Text("We use your location to show relevant results. Your location is never stored or shared.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + + Button("Enable Location") { + locationManager.requestWhenInUseAuthorization() + } + .buttonStyle(.borderedProminent) + + Button("Not Now") { dismiss() } + .foregroundStyle(.secondary) + } + .padding() + } +} +``` + +### 9.3 Support Sign in with Apple + +If the app offers any third-party sign-in (Google, Facebook), it must also offer Sign in with Apple. Present it as the first option. + +### 9.4 Don't Require Accounts Unless Necessary + +Let users explore the app before requiring sign-in. Gate only features that genuinely need authentication (purchases, sync, social features). + +### 9.5 App Tracking Transparency + +If you track users across apps or websites, display the ATT prompt. Respect denial—do not degrade the experience for users who opt out. + +### 9.6 Location Button for One-Time Access + +Use `LocationButton` for actions that need location once without requesting ongoing permission. + +```swift +LocationButton(.currentLocation) { + fetchNearbyResults() +} +.symbolVariant(.fill) +.labelStyle(.titleAndIcon) +``` + +--- + +## 10. System Integration + +**Impact:** MEDIUM + +### 10.1 Widgets for Glanceable Data + +Provide widgets using WidgetKit for information users check frequently. Widgets are not interactive (beyond tapping to open the app), so show the most useful snapshot. + +### 10.2 App Shortcuts for Key Actions + +Define App Shortcuts so users can trigger key actions from Siri, Spotlight, and the Shortcuts app. + +```swift +struct MyAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: QuickAddIntent(), + phrases: ["Add item in \(.applicationName)"], + shortTitle: "Quick Add", + systemImageName: "plus.circle" + ) + } +} +``` + +### 10.3 Spotlight Indexing + +Index app content with `CSSearchableItem` so users can find it from Spotlight search. + +### 10.4 Share Sheet Integration + +Support the system share sheet for content that users might want to send elsewhere. + +```swift +ShareLink(item: article.url, subject: Text(article.title)) { + Label("Share Article", systemImage: "square.and.arrow.up") +} +``` + +### 10.5 Live Activities + +Use Live Activities and the Dynamic Island for real-time, time-bound events (delivery tracking, sports scores, workouts). + +### 10.6 Handle Interruptions Gracefully + +Save state and pause gracefully when interrupted by phone calls, Siri invocations, notifications, app switcher, or FaceTime SharePlay. + +```swift +@Environment(\.scenePhase) private var scenePhase + +var body: some View { + ContentView() + .onChange(of: scenePhase) { _, newPhase in + switch newPhase { + case .active: + resumeActivity() + case .inactive: + pauseActivity() + case .background: + saveState() + @unknown default: + break + } + } +} +``` + +--- + +## Quick Reference + +### Navigation & Structure + +| Component | When to Use | +|-----------|-------------| +| `TabView` | 3-5 main app sections | +| `NavigationStack` | Hierarchical content drill-down | +| `.sheet` | Focused tasks requiring user completion | +| `.alert` | Decisions that block workflow | +| `.contextMenu` | Additional actions (always provide alternatives) | + +### Data Display + +| Component | When to Use | +|-----------|-------------| +| `List` | Scrollable rows with sections | +| `LazyVGrid` / `LazyHGrid` | Grid layouts | +| `.searchable` | Filterable content | +| `ProgressView` | Loading or task progress | + +### User Input + +| Component | When to Use | +|-----------|-------------| +| `TextField` | Single-line text | +| `TextEditor` | Multi-line text | +| `Picker` | Selection from options | +| `Toggle` | Binary on/off choice | +| `Stepper` | Numeric increment/decrement | + +### System Features + +| Component | When to Use | +|-----------|-------------| +| `ShareLink` | Content sharing | +| `LocationButton` | One-time location access | +| `PhotosPicker` | Image selection | +| `UIImpactFeedbackGenerator` | Tactile response | + +--- + +## Anti-Patterns + +Avoid these common HIG violations: + +| Pattern | Problem | Solution | +|---------|---------|----------| +| Hamburger/drawer menu | Hides navigation, users miss features | Use TabView with 3-5 tabs | +| Broken back swipe | Custom gestures block system navigation | Keep NavigationStack default behavior | +| Full-screen spinner | App feels frozen, no progress indication | Use skeleton views with `.redacted()` | +| Logo splash screen | Artificial delay, wastes user time | Match launch screen to first view | +| Permissions at launch | Users deny without context | Request when action requires it | +| Fixed font sizes | Breaks Dynamic Type, accessibility issues | Use `.font(.body)` semantic styles | +| Color-only status | Colorblind users miss information | Add icons or text labels | +| Alert overuse | Interrupts flow for minor info | Use inline messages or banners | +| Hidden tab bar | Users lose navigation context | Keep tab bar visible on push | +| Content in unsafe areas | Text hidden under notch/Dynamic Island | Only ignore safe area for backgrounds | +| No modal dismiss | Users trapped in view | Add cancel button and swipe dismiss | +| Gesture-only actions | Accessibility users blocked | Provide button/menu alternatives | +| Small tap targets | Frequent mis-taps | Minimum 44x44pt hit area | +| Nested modals | Navigation confusion | Use NavigationStack within single sheet | +| Hardcoded colors | Broken in Dark Mode | Use semantic colors or asset variants | + +--- + +## Review Checklist + +Code review checklist for SwiftUI apps: + +### Layout +- [ ] Interactive elements have 44pt minimum touch area +- [ ] Essential content stays within safe area bounds +- [ ] Main actions positioned for one-handed use (bottom) +- [ ] UI works across iPhone SE to Pro Max screen sizes +- [ ] Spacing uses 8pt increments + +### Navigation +- [ ] Main sections use bottom TabView (3-5 tabs) +- [ ] No drawer/hamburger navigation +- [ ] Root views show large navigation titles +- [ ] System back gesture not blocked +- [ ] Tab state persists when switching + +### Text & Fonts +- [ ] Text uses semantic styles (`.body`, `.headline`, etc.) +- [ ] Dynamic Type works at all sizes including accessibility +- [ ] Content reflows without truncation at large sizes +- [ ] No text below 11pt + +### Colors +- [ ] Uses `.primary`, `.secondary`, `Color(.systemBackground)` +- [ ] Custom colors have light/dark variants in assets +- [ ] Status indicators combine color with icon/text +- [ ] Text contrast ratio meets WCAG AA + +### Accessibility +- [ ] Icon buttons have `.accessibilityLabel()` +- [ ] VoiceOver order matches logical flow +- [ ] Animations respect `accessibilityReduceMotion` +- [ ] All actions have non-gesture alternatives + +### Modals & Alerts +- [ ] Alerts reserved for critical decisions only +- [ ] Sheets provide clear dismiss mechanism +- [ ] No stacked modal presentations + +### Permissions +- [ ] Permissions requested at point of use +- [ ] Pre-permission explanation screens used +- [ ] Core features work without sign-in + +--- + +## iPad Adaptation + +iPad users expect different interaction patterns: + +**Layout:** Use `NavigationSplitView` for master-detail: + +```swift +NavigationSplitView(columnVisibility: $columnVisibility) { + SidebarView() +} content: { + ListContentView() +} detail: { + DetailView() +} +.navigationSplitViewStyle(.balanced) +``` + +**Presentation:** Action sheets become popovers automatically, but you can force popover: + +```swift +.popover(isPresented: $showOptions) { + OptionsView() +} +``` + +**Keyboard:** Add shortcuts for power users: + +```swift +.keyboardShortcut("n", modifiers: .command) // Cmd+N +``` + +**Drag & Drop:** Enable cross-app data transfer: + +```swift +.draggable(item) +.dropDestination(for: Item.self) { items, location in + handleDrop(items) + return true +} +``` + +--- + +## Pre-Release Verification + +Run through these scenarios before shipping: + +**Visual consistency:** +- Switch between Light/Dark mode—does everything remain readable? +- Crank Dynamic Type to maximum—does layout adapt or break? +- Enable Bold Text—do custom fonts respond? + +**Interaction quality:** +- Can you complete every action using only VoiceOver? +- Do all buttons feel tappable on first try (no mis-taps)? +- Does back-swipe work everywhere in navigation? + +**Edge cases:** +- What happens on iPhone SE's small screen? +- What happens on iPad with keyboard attached? +- What shows when network fails mid-operation? +- What happens if user denies permissions? + +**Platform compliance:** +- Are you using SF Symbols instead of custom icon PNGs? +- Are all colors from semantic palette or asset catalog with variants? +- Do destructive actions require explicit confirmation? + +--- + +*SwiftUI, SF Symbols, Dynamic Island, and Apple are trademarks of Apple Inc.* diff --git a/skills/ios-application-dev/references/system-integration.md b/skills/ios-application-dev/references/system-integration.md new file mode 100644 index 0000000..754622e --- /dev/null +++ b/skills/ios-application-dev/references/system-integration.md @@ -0,0 +1,401 @@ +# System Integration + +iOS system integration guide covering permissions, location, sharing, app lifecycle, and haptic feedback. + +## Permission Requests + +Request permissions contextually, not at launch: + +```swift +import AVFoundation + +@objc func openCamera() { + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + if granted { + self?.showCameraInterface() + } else { + self?.showPermissionDeniedAlert() + } + } + } +} +``` + +### Photo Library + +```swift +import Photos + +func requestPhotoAccess() { + PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in + DispatchQueue.main.async { + switch status { + case .authorized, .limited: + self.showPhotoPicker() + case .denied, .restricted: + self.showSettingsAlert() + default: + break + } + } + } +} +``` + +### Microphone + +```swift +func requestMicrophoneAccess() { + AVAudioSession.sharedInstance().requestRecordPermission { granted in + DispatchQueue.main.async { + if granted { + self.startRecording() + } + } + } +} +``` + +### Notifications + +```swift +import UserNotifications + +func requestNotificationPermission() { + UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .badge, .sound] + ) { granted, error in + DispatchQueue.main.async { + if granted { + self.registerForRemoteNotifications() + } + } + } +} +``` + +## Location Button + +For one-time location access without persistent permission: + +```swift +import CoreLocationUI + +class StoreFinderVC: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + + let locationBtn = CLLocationButton() + locationBtn.icon = .arrowFilled + locationBtn.label = .currentLocation + locationBtn.cornerRadius = 20 + locationBtn.addTarget(self, action: #selector(findNearby), for: .touchUpInside) + + view.addSubview(locationBtn) + locationBtn.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-24) + } + } +} +``` + +### Core Location + +```swift +import CoreLocation + +class LocationManager: NSObject, CLLocationManagerDelegate { + private let manager = CLLocationManager() + + func requestLocation() { + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyBest + manager.requestWhenInUseAuthorization() + } + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + switch manager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: + manager.requestLocation() + case .denied: + showLocationDeniedAlert() + default: + break + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + handleLocation(location) + } +} +``` + +## Share Sheet + +```swift +@objc func shareContent() { + let items: [Any] = [contentURL, contentImage].compactMap { $0 } + let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil) + + if let popover = activityVC.popoverPresentationController { + popover.sourceView = shareButton + popover.sourceRect = shareButton.bounds + } + + present(activityVC, animated: true) +} +``` + +### Custom Share Items + +```swift +class ShareItem: NSObject, UIActivityItemSource { + let title: String + let url: URL + + init(title: String, url: URL) { + self.title = title + self.url = url + } + + func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { + return url + } + + func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? { + return url + } + + func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String { + return title + } +} +``` + +### Excluding Activities + +```swift +let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil) +activityVC.excludedActivityTypes = [ + .addToReadingList, + .assignToContact, + .print +] +``` + +## App Lifecycle + +```swift +class PlayerViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + + NotificationCenter.default.addObserver( + self, selector: #selector(onBackground), + name: UIApplication.didEnterBackgroundNotification, object: nil + ) + NotificationCenter.default.addObserver( + self, selector: #selector(onForeground), + name: UIApplication.willEnterForegroundNotification, object: nil + ) + NotificationCenter.default.addObserver( + self, selector: #selector(onTerminate), + name: UIApplication.willTerminateNotification, object: nil + ) + } + + @objc private func onBackground() { + saveState() + pausePlayback() + } + + @objc private func onForeground() { + restoreState() + resumePlayback() + } + + @objc private func onTerminate() { + saveState() + } +} +``` + +### Scene Lifecycle (iOS 13+) + +```swift +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + func sceneDidBecomeActive(_ scene: UIScene) { + // Resume tasks + } + + func sceneWillResignActive(_ scene: UIScene) { + // Pause tasks + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Save state + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Prepare UI + } +} +``` + +### State Preservation + +```swift +class ViewController: UIViewController { + override func encodeRestorableState(with coder: NSCoder) { + super.encodeRestorableState(with: coder) + coder.encode(currentItemID, forKey: "currentItemID") + } + + override func decodeRestorableState(with coder: NSCoder) { + super.decodeRestorableState(with: coder) + if let itemID = coder.decodeObject(forKey: "currentItemID") as? String { + loadItem(itemID) + } + } +} +``` + +## Haptic Feedback + +```swift +func onTaskComplete() { + UINotificationFeedbackGenerator().notificationOccurred(.success) +} + +func onError() { + UINotificationFeedbackGenerator().notificationOccurred(.error) +} + +func onWarning() { + UINotificationFeedbackGenerator().notificationOccurred(.warning) +} + +func onSelection() { + UISelectionFeedbackGenerator().selectionChanged() +} + +func onImpact() { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() +} +``` + +### Impact Styles + +| Style | Usage | +|-------|-------| +| `.light` | Subtle feedback, small UI changes | +| `.medium` | Standard feedback, button presses | +| `.heavy` | Strong feedback, significant actions | +| `.soft` | Gentle feedback, background changes | +| `.rigid` | Sharp feedback, collisions | + +### Prepared Feedback + +For time-critical haptics, prepare the generator in advance: + +```swift +class DraggableView: UIView { + private let impactGenerator = UIImpactFeedbackGenerator(style: .medium) + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + impactGenerator.prepare() + } + + func didSnapToPosition() { + impactGenerator.impactOccurred() + } +} +``` + +## Deep Linking + +### URL Schemes + +```swift +// In AppDelegate or SceneDelegate +func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + return false + } + + switch components.host { + case "item": + if let itemID = components.queryItems?.first(where: { $0.name == "id" })?.value { + navigateToItem(itemID) + return true + } + default: + break + } + + return false +} +``` + +### Universal Links + +```swift +func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, + let url = userActivity.webpageURL else { + return false + } + + return handleUniversalLink(url) +} +``` + +## Background Tasks + +```swift +import BackgroundTasks + +func registerBackgroundTasks() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.app.refresh", + using: nil + ) { task in + self.handleAppRefresh(task: task as! BGAppRefreshTask) + } +} + +func scheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh") + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + print("Could not schedule app refresh: \(error)") + } +} + +func handleAppRefresh(task: BGAppRefreshTask) { + scheduleAppRefresh() + + let operation = RefreshOperation() + + task.expirationHandler = { + operation.cancel() + } + + operation.completionBlock = { + task.setTaskCompleted(success: !operation.isCancelled) + } + + OperationQueue.main.addOperation(operation) +} +``` + +--- + +*UIKit, Core Location, and Apple are trademarks of Apple Inc.* diff --git a/skills/ios-application-dev/references/uikit-components.md b/skills/ios-application-dev/references/uikit-components.md new file mode 100644 index 0000000..cc826ba --- /dev/null +++ b/skills/ios-application-dev/references/uikit-components.md @@ -0,0 +1,297 @@ +# UIKit Components + +Common UIKit components guide covering UIStackView, buttons, alerts, search, and context menus. + +## UIStackView + +Stack views simplify auto layout for linear arrangements: + +```swift +class FormViewController: UIViewController { + private let mainStack = UIStackView() + + override func viewDidLoad() { + super.viewDidLoad() + + mainStack.axis = .vertical + mainStack.spacing = 16 + mainStack.alignment = .fill + mainStack.distribution = .fill + + view.addSubview(mainStack) + mainStack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(20) + make.leading.trailing.equalToSuperview().inset(16) + } + + let headerStack = UIStackView() + headerStack.axis = .horizontal + headerStack.spacing = 12 + headerStack.alignment = .center + + let avatarView = UIImageView() + avatarView.snp.makeConstraints { make in + make.size.equalTo(48) + } + + let labelStack = UIStackView() + labelStack.axis = .vertical + labelStack.spacing = 4 + labelStack.addArrangedSubview(titleLabel) + labelStack.addArrangedSubview(subtitleLabel) + + headerStack.addArrangedSubview(avatarView) + headerStack.addArrangedSubview(labelStack) + + mainStack.addArrangedSubview(headerStack) + mainStack.addArrangedSubview(contentView) + mainStack.addArrangedSubview(actionButton) + + mainStack.setCustomSpacing(24, after: headerStack) + } +} +``` + +### StackView Properties + +| Property | Options | Usage | +|----------|---------|-------| +| `axis` | `.horizontal`, `.vertical` | Layout direction | +| `distribution` | `.fill`, `.fillEqually`, `.fillProportionally`, `.equalSpacing`, `.equalCentering` | Space distribution | +| `alignment` | `.fill`, `.leading`, `.center`, `.trailing` | Cross-axis alignment | +| `spacing` | CGFloat | Uniform spacing | +| `setCustomSpacing(_:after:)` | - | Variable spacing | + +## UIButton.Configuration (iOS 15+) + +```swift +let primaryButton = UIButton(type: .system) +primaryButton.configuration = .filled() +primaryButton.setTitle("Continue", for: .normal) + +let secondaryButton = UIButton(type: .system) +secondaryButton.configuration = .tinted() +secondaryButton.setTitle("Save for Later", for: .normal) + +let destructiveButton = UIButton(type: .system) +destructiveButton.configuration = .plain() +destructiveButton.setTitle("Remove", for: .normal) +destructiveButton.tintColor = .systemRed +``` + +### Custom Button Configuration + +```swift +var config = UIButton.Configuration.filled() +config.title = "Add to Cart" +config.image = UIImage(systemName: "cart.badge.plus") +config.imagePadding = 8 +config.cornerStyle = .capsule +config.baseBackgroundColor = .systemBlue +config.baseForegroundColor = .white +let cartButton = UIButton(configuration: config) +``` + +### Button State Handling + +```swift +var config = UIButton.Configuration.filled() +config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = .boldSystemFont(ofSize: 16) + return outgoing +} + +config.configurationUpdateHandler = { button in + var config = button.configuration + config?.showsActivityIndicator = button.isSelected + button.configuration = config +} +``` + +## UIAlertController + +### Alert + +```swift +func confirmDeletion() { + let alert = UIAlertController( + title: "Remove Item?", + message: "This cannot be undone.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Remove", style: .destructive) { _ in + self.performDeletion() + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + present(alert, animated: true) +} +``` + +### Action Sheet + +```swift +func showOptions() { + let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + sheet.addAction(UIAlertAction(title: "Share", style: .default) { _ in }) + sheet.addAction(UIAlertAction(title: "Edit", style: .default) { _ in }) + sheet.addAction(UIAlertAction(title: "Delete", style: .destructive) { _ in }) + sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + if let popover = sheet.popoverPresentationController { + popover.sourceView = optionsButton + popover.sourceRect = optionsButton.bounds + } + + present(sheet, animated: true) +} +``` + +### Alert with Text Field + +```swift +func showInputAlert() { + let alert = UIAlertController( + title: "Rename", + message: "Enter a new name", + preferredStyle: .alert + ) + + alert.addTextField { textField in + textField.placeholder = "Name" + textField.autocapitalizationType = .words + } + + alert.addAction(UIAlertAction(title: "Save", style: .default) { _ in + if let name = alert.textFields?.first?.text { + self.rename(to: name) + } + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + present(alert, animated: true) +} +``` + +## UISearchController + +```swift +class SearchableListVC: UIViewController, UISearchResultsUpdating { + private let searchController = UISearchController(searchResultsController: nil) + private var allItems: [Item] = [] + + override func viewDidLoad() { + super.viewDidLoad() + setupSearch() + } + + private func setupSearch() { + searchController.searchResultsUpdater = self + searchController.obscuresBackgroundDuringPresentation = false + searchController.searchBar.placeholder = "Search" + navigationItem.searchController = searchController + definesPresentationContext = true + } + + func updateSearchResults(for searchController: UISearchController) { + let query = searchController.searchBar.text ?? "" + let filtered = query.isEmpty ? allItems : allItems.filter { + $0.title.localizedCaseInsensitiveContains(query) + } + updateItems(filtered) + } +} +``` + +### Search Bar Configuration + +```swift +searchController.searchBar.scopeButtonTitles = ["All", "Recent", "Favorites"] +searchController.searchBar.showsScopeBar = true +searchController.searchBar.delegate = self + +extension SearchableListVC: UISearchBarDelegate { + func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { + filterContent(scope: selectedScope) + } +} +``` + +## UIContextMenuInteraction + +```swift +extension PhotoCell: UIContextMenuInteractionDelegate { + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + configurationForMenuAtLocation location: CGPoint + ) -> UIContextMenuConfiguration? { + UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in + let share = UIAction( + title: "Share", + image: UIImage(systemName: "square.and.arrow.up") + ) { _ in } + + let favorite = UIAction( + title: "Favorite", + image: UIImage(systemName: "heart") + ) { _ in } + + let delete = UIAction( + title: "Delete", + image: UIImage(systemName: "trash"), + attributes: .destructive + ) { _ in } + + return UIMenu(children: [share, favorite, delete]) + } + } +} +``` + +### Context Menu with Preview + +```swift +func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + configurationForMenuAtLocation location: CGPoint +) -> UIContextMenuConfiguration? { + UIContextMenuConfiguration( + identifier: itemID as NSCopying, + previewProvider: { [weak self] in + return self?.makePreviewController() + }, + actionProvider: { _ in + return self.makeMenu() + } + ) +} + +func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, + animator: UIContextMenuInteractionCommitAnimating +) { + animator.addCompletion { + self.showDetail() + } +} +``` + +### CollectionView Context Menu + +```swift +func collectionView( + _ collectionView: UICollectionView, + contextMenuConfigurationForItemAt indexPath: IndexPath, + point: CGPoint +) -> UIContextMenuConfiguration? { + let item = dataSource.itemIdentifier(for: indexPath) + return UIContextMenuConfiguration(identifier: indexPath as NSCopying, previewProvider: nil) { _ in + return self.makeMenu(for: item) + } +} +``` + +--- + +*UIKit and Apple are trademarks of Apple Inc.* diff --git a/skills/shader-dev/SKILL.md b/skills/shader-dev/SKILL.md new file mode 100644 index 0000000..5b579b2 --- /dev/null +++ b/skills/shader-dev/SKILL.md @@ -0,0 +1,299 @@ +--- +name: shader-dev +description: Comprehensive GLSL shader techniques for creating stunning visual effects — ray marching, SDF modeling, fluid simulation, particle systems, procedural generation, lighting, post-processing, and more. +license: MIT +metadata: + version: "1.0" + category: graphics +--- + +# Shader Craft + +A unified skill covering 36 GLSL shader techniques (ShaderToy-compatible) for real-time visual effects. + +## Invocation + +``` +/shader-dev +``` + +`$ARGUMENTS` contains the user's request (e.g. "create a raymarched SDF scene with soft shadows"). + +## Skill Structure + +``` +shader-dev/ +├── SKILL.md # Core skill (this file) +├── techniques/ # Implementation guides (read per routing table) +│ ├── ray-marching.md # Sphere tracing with SDF +│ ├── sdf-3d.md # 3D signed distance functions +│ ├── lighting-model.md # PBR, Phong, toon shading +│ ├── procedural-noise.md # Perlin, Simplex, FBM +│ └── ... # 34 more technique files +└── reference/ # Detailed guides (read as needed) + ├── ray-marching.md # Math derivations & advanced patterns + ├── sdf-3d.md # Extended SDF theory + ├── lighting-model.md # Lighting math deep-dive + ├── procedural-noise.md # Noise function theory + └── ... # 34 more reference files +``` + +## How to Use + +1. Read the **Technique Routing Table** below to identify which technique(s) match the user's request +2. Read the relevant file(s) from `techniques/` — each file contains core principles, implementation steps, and complete code templates +3. If you need deeper understanding (math derivations, advanced patterns), follow the reference link at the bottom of each technique file to `reference/` +4. Apply the **WebGL2 Adaptation Rules** below when generating standalone HTML pages + +## Technique Routing Table + +| User wants to create... | Primary technique | Combine with | +|---|---|---| +| 3D objects / scenes from math | [ray-marching](techniques/ray-marching.md) + [sdf-3d](techniques/sdf-3d.md) | lighting-model, shadow-techniques | +| Complex 3D shapes (booleans, blends) | [csg-boolean-operations](techniques/csg-boolean-operations.md) | sdf-3d, ray-marching | +| Infinite repeating patterns in 3D | [domain-repetition](techniques/domain-repetition.md) | sdf-3d, ray-marching | +| Organic / warped shapes | [domain-warping](techniques/domain-warping.md) | procedural-noise | +| Fluid / smoke / ink effects | [fluid-simulation](techniques/fluid-simulation.md) | multipass-buffer | +| Particle effects (fire, sparks, snow) | [particle-system](techniques/particle-system.md) | procedural-noise, color-palette | +| Physically-based simulations | [simulation-physics](techniques/simulation-physics.md) | multipass-buffer | +| Game of Life / reaction-diffusion | [cellular-automata](techniques/cellular-automata.md) | multipass-buffer, color-palette | +| Ocean / water surface | [water-ocean](techniques/water-ocean.md) | atmospheric-scattering, lighting-model | +| Terrain / landscape | [terrain-rendering](techniques/terrain-rendering.md) | atmospheric-scattering, procedural-noise | +| Clouds / fog / volumetric fire | [volumetric-rendering](techniques/volumetric-rendering.md) | procedural-noise, atmospheric-scattering | +| Sky / sunset / atmosphere | [atmospheric-scattering](techniques/atmospheric-scattering.md) | volumetric-rendering | +| Realistic lighting (PBR, Phong) | [lighting-model](techniques/lighting-model.md) | shadow-techniques, ambient-occlusion | +| Shadows (soft / hard) | [shadow-techniques](techniques/shadow-techniques.md) | lighting-model | +| Ambient occlusion | [ambient-occlusion](techniques/ambient-occlusion.md) | lighting-model, normal-estimation | +| Path tracing / global illumination | [path-tracing-gi](techniques/path-tracing-gi.md) | analytic-ray-tracing, multipass-buffer | +| Precise ray-geometry intersections | [analytic-ray-tracing](techniques/analytic-ray-tracing.md) | lighting-model | +| Voxel worlds (Minecraft-style) | [voxel-rendering](techniques/voxel-rendering.md) | lighting-model, shadow-techniques | +| Noise / FBM textures | [procedural-noise](techniques/procedural-noise.md) | domain-warping | +| Tiled 2D patterns | [procedural-2d-pattern](techniques/procedural-2d-pattern.md) | polar-uv-manipulation | +| Voronoi / cell patterns | [voronoi-cellular-noise](techniques/voronoi-cellular-noise.md) | color-palette | +| Fractals (Mandelbrot, Julia, 3D) | [fractal-rendering](techniques/fractal-rendering.md) | color-palette, polar-uv-manipulation | +| Color grading / palettes | [color-palette](techniques/color-palette.md) | — | +| Bloom / tone mapping / glitch | [post-processing](techniques/post-processing.md) | multipass-buffer | +| Multi-pass ping-pong buffers | [multipass-buffer](techniques/multipass-buffer.md) | — | +| Texture / sampling techniques | [texture-sampling](techniques/texture-sampling.md) | — | +| Camera / matrix transforms | [matrix-transform](techniques/matrix-transform.md) | — | +| Surface normals | [normal-estimation](techniques/normal-estimation.md) | — | +| Polar coords / kaleidoscope | [polar-uv-manipulation](techniques/polar-uv-manipulation.md) | procedural-2d-pattern | +| 2D shapes / UI from SDF | [sdf-2d](techniques/sdf-2d.md) | color-palette | +| Procedural audio / music | [sound-synthesis](techniques/sound-synthesis.md) | — | +| SDF tricks / optimization | [sdf-tricks](techniques/sdf-tricks.md) | sdf-3d, ray-marching | +| Anti-aliased rendering | [anti-aliasing](techniques/anti-aliasing.md) | sdf-2d, post-processing | +| Depth of field / motion blur / lens effects | [camera-effects](techniques/camera-effects.md) | post-processing, multipass-buffer | +| Advanced texture mapping / no-tile textures | [texture-mapping-advanced](techniques/texture-mapping-advanced.md) | terrain-rendering, texture-sampling | +| WebGL2 shader errors / debugging | [webgl-pitfalls](techniques/webgl-pitfalls.md) | — | + +## Technique Index + +### Geometry & SDF +- **sdf-2d** — 2D signed distance functions for shapes, UI, anti-aliased rendering +- **sdf-3d** — 3D signed distance functions for real-time implicit surface modeling +- **csg-boolean-operations** — Constructive solid geometry: union, subtraction, intersection with smooth blending +- **domain-repetition** — Infinite space repetition, folding, and limited tiling +- **domain-warping** — Distort domains with noise for organic, flowing shapes +- **sdf-tricks** — SDF optimization, bounding volumes, binary search refinement, hollowing, layered edges, debug visualization + +### Ray Casting & Lighting +- **ray-marching** — Sphere tracing with SDF for 3D scene rendering +- **analytic-ray-tracing** — Closed-form ray-primitive intersections (sphere, plane, box, torus) +- **path-tracing-gi** — Monte Carlo path tracing for photorealistic global illumination +- **lighting-model** — Phong, Blinn-Phong, PBR (Cook-Torrance), and toon shading +- **shadow-techniques** — Hard shadows, soft shadows (penumbra estimation), cascade shadows +- **ambient-occlusion** — SDF-based AO, screen-space AO approximation +- **normal-estimation** — Finite-difference normals, tetrahedron technique + +### Simulation & Physics +- **fluid-simulation** — Navier-Stokes fluid solver with advection, diffusion, pressure projection +- **simulation-physics** — GPU-based physics: springs, cloth, N-body gravity, collision +- **particle-system** — Stateless and stateful particle systems (fire, rain, sparks, galaxies) +- **cellular-automata** — Game of Life, reaction-diffusion (Turing patterns), sand simulation + +### Natural Phenomena +- **water-ocean** — Gerstner waves, FFT ocean, caustics, underwater fog +- **terrain-rendering** — Heightfield ray marching, FBM terrain, erosion +- **atmospheric-scattering** — Rayleigh/Mie scattering, god rays, SSS approximation +- **volumetric-rendering** — Volume ray marching for clouds, fog, fire, explosions + +### Procedural Generation +- **procedural-noise** — Value noise, Perlin, Simplex, Worley, FBM, ridged noise +- **procedural-2d-pattern** — Brick, hexagon, truchet, Islamic geometric patterns +- **voronoi-cellular-noise** — Voronoi diagrams, Worley noise, cracked earth, crystal +- **fractal-rendering** — Mandelbrot, Julia sets, 3D fractals (Mandelbox, Mandelbulb) +- **color-palette** — Cosine palettes, HSL/HSV/Oklab, dynamic color mapping + +### Post-Processing & Infrastructure +- **post-processing** — Bloom, tone mapping (ACES, Reinhard), vignette, chromatic aberration, glitch +- **multipass-buffer** — Ping-pong FBO setup, state persistence across frames +- **texture-sampling** — Bilinear, bicubic, mipmap, procedural texture lookup +- **matrix-transform** — Camera look-at, projection, rotation, orbit controls +- **polar-uv-manipulation** — Polar/log-polar coordinates, kaleidoscope, spiral mapping +- **anti-aliasing** — SSAA, SDF analytical AA, temporal anti-aliasing (TAA), FXAA post-process +- **camera-effects** — Depth of field (thin lens), motion blur, lens distortion, film grain, vignette +- **texture-mapping-advanced** — Biplanar mapping, texture repetition avoidance, ray differential filtering + +### Audio +- **sound-synthesis** — Procedural audio in GLSL: oscillators, envelopes, filters, FM synthesis + +### Debugging & Validation +- **webgl-pitfalls** — Common WebGL2/GLSL errors: `fragCoord`, `main()` wrapper, function order, macro limitations, uniform null + +## WebGL2 Adaptation Rules + +All technique files use ShaderToy GLSL style. When generating standalone HTML pages, apply these adaptations: + +### Shader Version & Output +- Use `canvas.getContext("webgl2")` +- Shader first line: `#version 300 es`, fragment shader adds `precision highp float;` +- Fragment shader must declare: `out vec4 fragColor;` +- Vertex shader: `attribute` → `in`, `varying` → `out` +- Fragment shader: `varying` → `in`, `gl_FragColor` → `fragColor`, `texture2D()` → `texture()` + +### Fragment Coordinate +- **Use `gl_FragCoord.xy`** instead of `fragCoord` (WebGL2 does not have `fragCoord` built-in) +```glsl +// WRONG +vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; +// CORRECT +vec2 uv = (2.0 * gl_FragCoord.xy - iResolution.xy) / iResolution.y; +``` + +### main() Wrapper for ShaderToy Templates +- ShaderToy uses `void mainImage(out vec4 fragColor, in vec2 fragCoord)` +- WebGL2 requires standard `void main()` entry point — always wrap mainImage: +```glsl +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + // shader code... + fragColor = vec4(col, 1.0); +} + +void main() { + mainImage(fragColor, gl_FragCoord.xy); +} +``` + +### Function Declaration Order +- GLSL requires functions to be declared before use — either declare before use or reorder: +```glsl +// WRONG — getAtmosphere() calls getSunDirection() before it's defined +vec3 getAtmosphere(vec3 dir) { return getSunDirection(); } // Error! +vec3 getSunDirection() { return normalize(vec3(1.0)); } + +// CORRECT — define callee first +vec3 getSunDirection() { return normalize(vec3(1.0)); } +vec3 getAtmosphere(vec3 dir) { return getSunDirection(); } // Works +``` + +### Macro Limitations +- `#define` cannot use function calls — use `const` instead: +```glsl +// WRONG +#define SUN_DIR normalize(vec3(0.8, 0.4, -0.6)) + +// CORRECT +const vec3 SUN_DIR = vec3(0.756, 0.378, -0.567); // Pre-computed normalized value +``` + +### Script Tag Extraction +- When extracting shader source from ` +``` + +### Buffer A (Simulation Computation) +```glsl +// Gray-Scott Reaction-Diffusion — Buffer A (Simulation) +// iChannel0 = Buffer A (self-feedback, linear filtering) + +#define DU 0.210 // u diffusion coefficient (0.1~0.3) +#define DV 0.105 // v diffusion coefficient (0.05~0.15) +#define F 0.040 // feed rate (0.01~0.08) +#define K 0.060 // kill rate (0.04~0.07) +#define DT 1.0 // time step (0.5~2.0) +#define INIT_FRAMES 10 + +float hash1(float n) { + return fract(sin(n) * 138.5453123); +} +vec3 hash33(vec2 p) { + float n = sin(dot(p, vec2(41.0, 289.0))); + return fract(vec3(2097152.0, 262144.0, 32768.0) * n); +} + +// Nine-point Laplacian: diagonal 0.05, cross 0.2, center -1.0 +vec2 laplacian9(vec2 uv) { + vec2 px = 1.0 / iResolution.xy; + vec2 c = texture(iChannel0, uv).xy; + vec2 n = texture(iChannel0, uv + vec2( 0, px.y)).xy; + vec2 s = texture(iChannel0, uv + vec2( 0,-px.y)).xy; + vec2 e = texture(iChannel0, uv + vec2( px.x, 0)).xy; + vec2 w = texture(iChannel0, uv + vec2(-px.x, 0)).xy; + vec2 ne = texture(iChannel0, uv + vec2( px.x, px.y)).xy; + vec2 nw = texture(iChannel0, uv + vec2(-px.x, px.y)).xy; + vec2 se = texture(iChannel0, uv + vec2( px.x,-px.y)).xy; + vec2 sw = texture(iChannel0, uv + vec2(-px.x,-px.y)).xy; + return (n + s + e + w) * 0.2 + (ne + nw + se + sw) * 0.05 - c; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + + // Initialization + if (iFrame < INIT_FRAMES) { + float rnd = hash1(fragCoord.x * 13.0 + hash1(fragCoord.y * 71.1 + float(iFrame))); + float u = 1.0; + float v = (rnd > 0.9) ? 1.0 : 0.0; + vec2 center = iResolution.xy * 0.5; + if (abs(fragCoord.x - center.x) < 20.0 && abs(fragCoord.y - center.y) < 20.0) { + v = hash1(fragCoord.x * 7.0 + fragCoord.y * 13.0) > 0.5 ? 1.0 : 0.0; + } + fragColor = vec4(u, v, 0.0, 1.0); + return; + } + + // Read current state + vec2 state = texture(iChannel0, uv).xy; + float u = state.x; + float v = state.y; + + // Gray-Scott equations + vec2 lap = laplacian9(uv); + float uvv = u * v * v; + float du = DU * lap.x - uvv + F * (1.0 - u); + float dv = DV * lap.y + uvv - (F + K) * v; + + u += du * DT; + v += dv * DT; + + // Mouse interaction: click to add v + if (iMouse.z > 0.0) { + if (length(fragCoord - iMouse.xy) < 10.0) v = 1.0; + } + + fragColor = vec4(clamp(u, 0.0, 1.0), clamp(v, 0.0, 1.0), 0.0, 1.0); +} +``` + +### Image (Visualization Output) +```glsl +// Gray-Scott Reaction-Diffusion — Image (Visualization) +// iChannel0 = Buffer A (linear filtering) + +#define LIGHT_STRENGTH 12.0 // specular intensity (5~20) +#define COLOR_MODE 0 // 0=blue-gold, 1=flame, 2=monochrome +#define VIGNETTE 1 // 0=off, 1=vignette on + +vec3 getNormal(vec2 uv) { + vec2 d = 1.0 / iResolution.xy; + float du = texture(iChannel0, uv + vec2(d.x, 0)).y - texture(iChannel0, uv - vec2(d.x, 0)).y; + float dv = texture(iChannel0, uv + vec2(0, d.y)).y - texture(iChannel0, uv - vec2(0, d.y)).y; + return normalize(vec3(du, dv, 0.05)); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + float val = texture(iChannel0, uv).y; + float c = 1.0 - val; + + vec3 col; + #if COLOR_MODE == 0 + float pattern = -cos(uv.x*0.75*3.14159-0.9)*cos(uv.y*1.5*3.14159-0.75)*0.5+0.5; + col = pow(vec3(1.5, 1.0, 1.0) * c, vec3(1.0, 4.0, 12.0)); + col = mix(col, col.zyx, clamp(pattern - 0.2, 0.0, 1.0)); + #elif COLOR_MODE == 1 + col = vec3(c * 1.2, pow(c, 3.0), pow(c, 9.0)); + #else + col = vec3(c); + #endif + + float c2 = 1.0 - texture(iChannel0, uv + 0.5 / iResolution.xy).y; + col += vec3(0.36, 0.73, 1.0) * max(c2*c2 - c*c, 0.0) * LIGHT_STRENGTH; + + #if VIGNETTE == 1 + col *= pow(16.0*uv.x*uv.y*(1.0-uv.x)*(1.0-uv.y), 0.125) * 1.15; + #endif + col *= smoothstep(0.0, 1.0, iTime / 2.0); + fragColor = vec4(sqrt(clamp(col, 0.0, 1.0)), 1.0); +} +``` + +## Common Variants + +### Variant 1: Conway's Game of Life (Discrete CA) +```glsl +int cell(in ivec2 p) { + ivec2 r = ivec2(textureSize(iChannel0, 0)); + p = (p + r) % r; + return (texelFetch(iChannel0, p, 0).x > 0.5) ? 1 : 0; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + ivec2 px = ivec2(fragCoord); + int k = cell(px+ivec2(-1,-1)) + cell(px+ivec2(0,-1)) + cell(px+ivec2(1,-1)) + + cell(px+ivec2(-1, 0)) + cell(px+ivec2(1, 0)) + + cell(px+ivec2(-1, 1)) + cell(px+ivec2(0, 1)) + cell(px+ivec2(1, 1)); + int e = cell(px); + float f = (((k == 2) && (e == 1)) || (k == 3)) ? 1.0 : 0.0; + if (iFrame < 2) + f = step(0.9, fract(sin(fragCoord.x*13.0 + sin(fragCoord.y*71.1)) * 138.5)); + fragColor = vec4(f, 0.0, 0.0, 1.0); +} +``` + +### Variant 2: Configurable Rule Set CA (B/S Bitmask) +```glsl +#define BORN_SET 8 // birth bitmask, 8 = B3 +#define STAY_SET 12 // survival bitmask, 12 = S23 +#define LIVEVAL 2.0 +#define DECIMATE 1.0 // decay value + +float ff = 0.0; +float ev = texelFetch(iChannel0, px, 0).w; +if (ev > 0.5) { + if (DECIMATE > 0.0) ff = ev - DECIMATE; + if ((STAY_SET & (1 << (k - 1))) > 0) ff = LIVEVAL; +} else { + ff = ((BORN_SET & (1 << (k - 1))) > 0) ? LIVEVAL : 0.0; +} +``` + +### Variant 3: Separable Gaussian Blur RD (Multi-Buffer) +```glsl +// Buffer B: horizontal blur (reads Buffer A) +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + float h = 1.0 / iResolution.x; + vec4 sum = vec4(0.0); + sum += texture(iChannel0, fract(vec2(uv.x - 4.0*h, uv.y))) * 0.05; + sum += texture(iChannel0, fract(vec2(uv.x - 3.0*h, uv.y))) * 0.09; + sum += texture(iChannel0, fract(vec2(uv.x - 2.0*h, uv.y))) * 0.12; + sum += texture(iChannel0, fract(vec2(uv.x - 1.0*h, uv.y))) * 0.15; + sum += texture(iChannel0, fract(vec2(uv.x, uv.y))) * 0.16; + sum += texture(iChannel0, fract(vec2(uv.x + 1.0*h, uv.y))) * 0.15; + sum += texture(iChannel0, fract(vec2(uv.x + 2.0*h, uv.y))) * 0.12; + sum += texture(iChannel0, fract(vec2(uv.x + 3.0*h, uv.y))) * 0.09; + sum += texture(iChannel0, fract(vec2(uv.x + 4.0*h, uv.y))) * 0.05; + fragColor = vec4(sum.xyz / 0.98, 1.0); +} +// Buffer C: vertical blur (reads Buffer B), same structure but along y-axis +// Buffer A: reaction step reads Buffer C as the diffusion term +``` + +### Variant 4: Continuous Differential Operator CA (Vein/Fluid Style) +```glsl +#define STEPS 40 // advection step count (10~60) +#define ts 0.2 // advection rotation strength +#define cs -2.0 // curl scale +#define ls 0.05 // Laplacian scale +#define amp 1.0 // self-amplification coefficient +#define upd 0.4 // update smoothing coefficient + +// 3x3 discrete curl and divergence +curl = uv_n.x - uv_s.x - uv_e.y + uv_w.y + + _D * (uv_nw.x + uv_nw.y + uv_ne.x - uv_ne.y + + uv_sw.y - uv_sw.x - uv_se.y - uv_se.x); +div = uv_s.y - uv_n.y - uv_e.x + uv_w.x + + _D * (uv_nw.x - uv_nw.y - uv_ne.x - uv_ne.y + + uv_sw.x + uv_sw.y + uv_se.y - uv_se.x); + +// Multi-step advection loop +for (int i = 0; i < STEPS; i++) { + advect(off, vUv, texel, curl, div, lapl, blur); + offd = rot(offd, ts * curl); + off += offd; + ab += blur / float(STEPS); +} +``` + +### Variant 5: RD-Driven 3D Surface (Raymarched RD) +```glsl +// Image pass: use RD texture for displacement in SDF +vec2 map(in vec3 pos) { + vec3 p = normalize(pos); + vec2 uv; + uv.x = 0.5 + atan(p.z, p.x) / (2.0 * 3.14159); + uv.y = 0.5 - asin(p.y) / 3.14159; + float y = texture(iChannel0, uv).y; + float displacement = 0.1 * y; + float sd = length(pos) - (2.0 + displacement); + return vec2(sd, y); +} +``` + +## Performance & Composition + +### Performance Tips +- **texelFetch vs texture**: Use `texelFetch` for discrete CA (exact pixel reads), `texture` for continuous RD (bilinear interpolation) +- **Separable blur replaces large kernels**: For large diffusion radii, use two-pass separable Gaussian (O(2N)) instead of NxN Laplacian (O(N²)) +- **Sub-iterations**: Multiple small DT steps within a single frame improves stability +- **Reduced resolution**: Low-resolution buffer simulation + Image pass upsampling +- **Avoid branching**: Use `step()/mix()/clamp()` instead of `if/else` + +### Composition Directions +- **RD + Raymarching**: RD as heightmap mapped onto 3D surface for displacement modeling +- **CA/RD + Particle Systems**: Field used as velocity field or spawn probability field to drive particles +- **RD + Bump Lighting**: Compute normals from RD values, combine with environment maps for metallic etching/ripple effects +- **CA + Color Decay Trails**: After death, fade per-frame with different RGB decay rates producing colored trails +- **RD + Domain Transforms**: Apply vortex/spiral transforms before sampling, producing spiral swirl patterns + +## Further Reading + +Full step-by-step tutorial, mathematical derivations, and advanced usage in [reference](../reference/cellular-automata.md) diff --git a/skills/shader-dev/techniques/color-palette.md b/skills/shader-dev/techniques/color-palette.md new file mode 100644 index 0000000..2c5a47f --- /dev/null +++ b/skills/shader-dev/techniques/color-palette.md @@ -0,0 +1,380 @@ +# Color Palette & Color Space + +## Use Cases +- Mapping scalar values (distance, temperature, time, iteration count) to continuous color ramps +- Perceptually uniform color interpolation/gradients +- HDR rendering with linear-space workflow (sRGB decode -> shading -> tone mapping -> sRGB encode) +- Physically realistic glow/flame/blackbody radiation colors + +## Core Principles + +Core: **map a scalar t in [0,1] to an RGB vec3**. + +### Cosine Palette +``` +color(t) = a + b * cos(2pi * (c * t + d)) +``` +- **a** = brightness offset (~0.5), **b** = amplitude (~0.5), **c** = frequency, **d** = phase (the key parameter controlling color style) + +### HSV/HSL Branchless Conversion +``` +rgb = clamp(abs(mod(H*6 + vec3(0,4,2), 6) - 3) - 1, 0, 1) +``` +Uses piecewise linear functions to approximate RGB variation with hue. C1 continuity can be achieved via `rgb*rgb*(3-2*rgb)`. + +### CIE Lab/Lch Perceptually Uniform Interpolation +RGB -> XYZ -> Lab -> Lch pipeline; interpolate in perceptually uniform space to avoid brightness discontinuities in RGB/HSV. + +### Blackbody Radiation Palette +Temperature T -> Planckian locus approximation -> CIE chromaticity -> XYZ -> RGB, with Stefan-Boltzmann (T^4) controlling brightness. + +## Implementation + +### Cosine Palette +```glsl +// a: offset, b: amplitude, c: frequency, d: phase, t: input scalar +vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) { + return a + b * cos(6.28318 * (c * t + d)); +} +``` + +### Classic Preset Parameters +```glsl +// Rainbow: d=(0.0, 0.33, 0.67) +// Warm: d=(0.0, 0.10, 0.20) +// Blue-purple to orange: c=(1,0.7,0.4) d=(0.0, 0.15, 0.20) +// Warm-cool mix: a=(.8,.5,.4) b=(.2,.4,.2) c=(2,1,1) d=(0.0, 0.25, 0.25) + +// Simplified version: fixed a/b/c, only adjust d +vec3 palette(float t) { + vec3 a = vec3(0.5, 0.5, 0.5); + vec3 b = vec3(0.5, 0.5, 0.5); + vec3 c = vec3(1.0, 1.0, 1.0); + vec3 d = vec3(0.263, 0.416, 0.557); + return a + b * cos(6.28318 * (c * t + d)); +} +``` + +### HSV -> RGB (Standard + Smooth) +```glsl +// Standard HSV -> RGB (branchless) +vec3 hsv2rgb(vec3 c) { + vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0); + return c.z * mix(vec3(1.0), rgb, c.y); +} + +// Smooth version (C1 continuous) +vec3 hsv2rgb_smooth(vec3 c) { + vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0); + rgb = rgb * rgb * (3.0 - 2.0 * rgb); // Hermite smoothing + return c.z * mix(vec3(1.0), rgb, c.y); +} +``` + +### HSL -> RGB +```glsl +vec3 hue2rgb(float h) { + return clamp(abs(mod(h * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0); +} + +vec3 hsl2rgb(float h, float s, float l) { + vec3 rgb = hue2rgb(h); + return l + s * (rgb - 0.5) * (1.0 - abs(2.0 * l - 1.0)); +} +``` + +### RGB -> HSV +```glsl +// Sam Hocevar branchless method +vec3 rgb2hsv(vec3 c) { + vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); + vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); + vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); + float d = q.x - min(q.w, q.y); + float e = 1.0e-10; + return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); +} +``` + +### CIE Lab/Lch Conversion Pipeline +```glsl +float xyzF(float t) { return mix(pow(t, 1.0/3.0), 7.787037 * t + 0.139731, step(t, 0.00885645)); } +float xyzR(float t) { return mix(t * t * t, 0.1284185 * (t - 0.139731), step(t, 0.20689655)); } + +vec3 rgb2lch(vec3 c) { + c *= mat3(0.4124, 0.3576, 0.1805, + 0.2126, 0.7152, 0.0722, + 0.0193, 0.1192, 0.9505); + c = vec3(xyzF(c.x), xyzF(c.y), xyzF(c.z)); + vec3 lab = vec3(max(0.0, 116.0 * c.y - 16.0), + 500.0 * (c.x - c.y), + 200.0 * (c.y - c.z)); + return vec3(lab.x, length(lab.yz), atan(lab.z, lab.y)); +} + +vec3 lch2rgb(vec3 c) { + c = vec3(c.x, cos(c.z) * c.y, sin(c.z) * c.y); + float lg = (1.0 / 116.0) * (c.x + 16.0); + vec3 xyz = vec3(xyzR(lg + 0.002 * c.y), + xyzR(lg), + xyzR(lg - 0.005 * c.z)); + return xyz * mat3( 3.2406, -1.5372, -0.4986, + -0.9689, 1.8758, 0.0415, + 0.0557, -0.2040, 1.0570); +} + +// Circular hue interpolation +float lerpAngle(float a, float b, float x) { + float ang = mod(mod((a - b), 6.28318) + 9.42477, 6.28318) - 3.14159; + return ang * x + b; +} + +vec3 lerpLch(vec3 a, vec3 b, float x) { + return vec3(mix(b.xy, a.xy, x), lerpAngle(a.z, b.z, x)); +} +``` + +### sRGB Gamma & Tone Mapping +```glsl +// Precise sRGB encoding +float sRGB_encode(float t) { + return mix(1.055 * pow(t, 1.0/2.4) - 0.055, 12.92 * t, step(t, 0.0031308)); +} +vec3 sRGB_encode(vec3 c) { + return vec3(sRGB_encode(c.x), sRGB_encode(c.y), sRGB_encode(c.z)); +} + +// Fast approximation: pow(color, vec3(2.2)) / pow(color, vec3(1.0/2.2)) + +// Reinhard tone mapping +vec3 tonemap_reinhard(vec3 col) { + return col / (1.0 + col); +} +``` + +### Blackbody Radiation Palette +```glsl +#define TEMP_MAX 4000.0 // Tunable: maximum temperature (K) +vec3 blackbodyPalette(float t) { + t *= TEMP_MAX; + float cx = (0.860117757 + 1.54118254e-4 * t + 1.28641212e-7 * t * t) + / (1.0 + 8.42420235e-4 * t + 7.08145163e-7 * t * t); + float cy = (0.317398726 + 4.22806245e-5 * t + 4.20481691e-8 * t * t) + / (1.0 - 2.89741816e-5 * t + 1.61456053e-7 * t * t); + float d = 2.0 * cx - 8.0 * cy + 4.0; + vec3 XYZ = vec3(3.0 * cx / d, 2.0 * cy / d, 1.0 - (3.0 * cx + 2.0 * cy) / d); + vec3 RGB = mat3(3.240479, -0.969256, 0.055648, + -1.537150, 1.875992, -0.204043, + -0.498535, 0.041556, 1.057311) * vec3(XYZ.x / XYZ.y, 1.0, XYZ.z / XYZ.y); + return max(RGB, 0.0) * pow(t * 0.0004, 4.0); +} +``` + +## Complete Code Template + +A ShaderToy shader demonstrating all core techniques: + +```glsl +// === Procedural Color Palette Showcase === +#define PI 3.14159265 +#define TAU 6.28318530 + +// ============ Tunable Parameters ============ +#define PALETTE_A vec3(0.5, 0.5, 0.5) // Offset: increase = brighter overall +#define PALETTE_B vec3(0.5, 0.5, 0.5) // Amplitude: increase = more contrast +#define PALETTE_C vec3(1.0, 1.0, 1.0) // Frequency: increase = denser color variation +#define PALETTE_D vec3(0.0, 0.33, 0.67) // Phase: change = completely different hues +#define TEMP_MAX 4000.0 // Blackbody max temperature (K) +#define NUM_ITER 4 // Fractal iteration count + +// ============ Color Functions ============ + +vec3 cosinePalette(float t, vec3 a, vec3 b, vec3 c, vec3 d) { + return a + b * cos(TAU * (c * t + d)); +} + +vec3 palette(float t) { + return cosinePalette(t, PALETTE_A, PALETTE_B, PALETTE_C, PALETTE_D); +} + +vec3 hsv2rgb(vec3 c) { + vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0); + rgb = rgb * rgb * (3.0 - 2.0 * rgb); + return c.z * mix(vec3(1.0), rgb, c.y); +} + +vec3 hsl2rgb(float h, float s, float l) { + vec3 rgb = clamp(abs(mod(h * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0); + return l + s * (rgb - 0.5) * (1.0 - abs(2.0 * l - 1.0)); +} + +vec3 blackbodyPalette(float t) { + t *= TEMP_MAX; + float cx = (0.860117757 + 1.54118254e-4*t + 1.28641212e-7*t*t) + / (1.0 + 8.42420235e-4*t + 7.08145163e-7*t*t); + float cy = (0.317398726 + 4.22806245e-5*t + 4.20481691e-8*t*t) + / (1.0 - 2.89741816e-5*t + 1.61456053e-7*t*t); + float d = 2.0*cx - 8.0*cy + 4.0; + vec3 XYZ = vec3(3.0*cx/d, 2.0*cy/d, 1.0 - (3.0*cx + 2.0*cy)/d); + vec3 RGB = mat3(3.240479, -0.969256, 0.055648, + -1.537150, 1.875992, -0.204043, + -0.498535, 0.041556, 1.057311) * vec3(XYZ.x/XYZ.y, 1.0, XYZ.z/XYZ.y); + return max(RGB, 0.0) * pow(t * 0.0004, 4.0); +} + +vec3 sRGB(vec3 c) { return pow(clamp(c, 0.0, 1.0), vec3(1.0/2.2)); } +vec3 tonemap(vec3 c) { return c / (1.0 + c); } + +// ============ Main ============ + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y; + vec2 uv0 = uv; + float band = fragCoord.y / iResolution.y; + vec3 col = vec3(0.0); + + if (band < 0.2) { + // Cosine Palette + float t = fragCoord.x / iResolution.x + iTime * 0.1; + col = palette(t); + } else if (band < 0.4) { + // HSV color wheel + float h = fragCoord.x / iResolution.x; + float v = (band - 0.2) / 0.2; + col = hsv2rgb(vec3(h + iTime * 0.05, 1.0, v)); + } else if (band < 0.6) { + // HSL color wheel + float h = fragCoord.x / iResolution.x; + float l = (band - 0.4) / 0.2; + col = hsl2rgb(h + iTime * 0.05, 1.0, l); + } else if (band < 0.8) { + // Blackbody radiation + float t = fragCoord.x / iResolution.x; + col = tonemap(blackbodyPalette(t)); + } else { + // Cosine Palette fractal glow + vec2 p = uv; + vec3 finalColor = vec3(0.0); + for (int i = 0; i < NUM_ITER; i++) { + p = fract(p * 1.5) - 0.5; + float d = length(p) * exp(-length(uv0)); + vec3 c = palette(length(uv0) + float(i) * 0.4 + iTime * 0.4); + d = sin(d * 8.0 + iTime) / 8.0; + d = abs(d); + d = pow(0.01 / d, 1.2); + finalColor += c * d; + } + col = tonemap(finalColor); + } + + // Band separator lines + float bandLine = smoothstep(0.003, 0.0, abs(fract(band * 5.0) - 0.5) - 0.49); + col *= 1.0 - bandLine * 0.8; + col = sRGB(col); + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Multi-Harmonic Cosine Palette (Anti-Aliased) +```glsl +vec3 fcos(vec3 x) { + vec3 w = fwidth(x); + return cos(x) * smoothstep(TAU, 0.0, w); +} + +vec3 getColor(float t) { + vec3 col = vec3(0.4); + col += 0.12 * fcos(TAU * t * 1.0 + vec3(0.0, 0.8, 1.1)); + col += 0.11 * fcos(TAU * t * 3.1 + vec3(0.3, 0.4, 0.1)); + col += 0.10 * fcos(TAU * t * 5.1 + vec3(0.1, 0.7, 1.1)); + col += 0.09 * fcos(TAU * t * 9.1 + vec3(0.2, 0.8, 1.4)); + col += 0.08 * fcos(TAU * t * 17.1 + vec3(0.2, 0.6, 0.7)); + col += 0.07 * fcos(TAU * t * 31.1 + vec3(0.1, 0.6, 0.7)); + col += 0.06 * fcos(TAU * t * 65.1 + vec3(0.0, 0.5, 0.8)); + col += 0.06 * fcos(TAU * t * 115.1 + vec3(0.1, 0.4, 0.7)); + col += 0.09 * fcos(TAU * t * 265.1 + vec3(1.1, 1.4, 2.7)); + return col; +} +``` + +### Hash-Driven Per-Tile Color +```glsl +float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +vec2 tileId = floor(uv); +vec3 tileColor = palette(hash12(tileId)); +``` + +### Saturation-Preserving Improved RGB Interpolation +```glsl +float getsat(vec3 c) { + float mi = min(min(c.x, c.y), c.z); + float ma = max(max(c.x, c.y), c.z); + return (ma - mi) / (ma + 1e-7); +} + +vec3 iLerp(vec3 a, vec3 b, float x) { + vec3 ic = mix(a, b, x) + vec3(1e-6, 0.0, 0.0); + float sd = abs(getsat(ic) - mix(getsat(a), getsat(b), x)); + vec3 dir = normalize(vec3(2.0*ic.x - ic.y - ic.z, + 2.0*ic.y - ic.x - ic.z, + 2.0*ic.z - ic.y - ic.x)); + float lgt = dot(vec3(1.0), ic); + float ff = dot(dir, normalize(ic)); + ic += 1.5 * dir * sd * ff * lgt; + return clamp(ic, 0.0, 1.0); +} +``` + +### Circular Hue Interpolation +```glsl +// HSV space (hue [0,1]) +vec3 lerpHSV(vec3 a, vec3 b, float x) { + float hue = (mod(mod((b.x - a.x), 1.0) + 1.5, 1.0) - 0.5) * x + a.x; + return vec3(hue, mix(a.yz, b.yz, x)); +} + +// Lch space (hue [0, 2pi]) +float lerpAngle(float a, float b, float x) { + float ang = mod(mod((a - b), TAU) + PI * 3.0, TAU) - PI; + return ang * x + b; +} +``` + +### Additive Color Stacking (Glow/HDR) +```glsl +vec3 finalColor = vec3(0.0); +for (int i = 0; i < 4; i++) { + vec3 col = palette(length(uv) + float(i) * 0.4 + iTime * 0.4); + float glow = pow(0.01 / abs(sdfValue), 1.2); + finalColor += col * glow; +} +finalColor = finalColor / (1.0 + finalColor); // Reinhard tonemap +``` + +## Performance & Composition + +**Performance tips:** +- Cosine Palette: ~3-4 clock cycles (1 MAD + 1 COS + 1 MAD) +- HSV/HSL conversion: fully branchless using `mod`/`abs`/`clamp` vectorization +- Multi-harmonic band-limited filtering: `fwidth()` + `smoothstep` adds ~2 extra instructions to eliminate aliasing +- Lch pipeline ~57 instructions; if you only need "slightly better than RGB", use `iLerp` (~15 instructions) instead +- sRGB approximation `pow(c, 2.2)` has <0.4% error and optimizes better in the compiler + +**Common combinations:** +- **Cosine Palette + SDF Raymarching**: normals/distance/attributes as t input +- **HSL/HSV + Data Visualization**: iteration count -> hue, saturation/brightness encode other dimensions +- **Cosine Palette + Fractals/Noise**: `length(uv)` or `fbm(p)` + `iTime` driving dynamic colors +- **Blackbody + Volume Rendering/Fire**: temperature field -> `blackbodyPalette()` -> physically plausible colors +- **Linear space workflow**: sRGB decode -> linear shading -> tonemap -> sRGB encode +- **Hash + Palette + Tiling**: `hash(tileID)` as palette input for unified color harmony + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/color-palette.md) diff --git a/skills/shader-dev/techniques/csg-boolean-operations.md b/skills/shader-dev/techniques/csg-boolean-operations.md new file mode 100644 index 0000000..ba17581 --- /dev/null +++ b/skills/shader-dev/techniques/csg-boolean-operations.md @@ -0,0 +1,491 @@ +## WebGL2 Adaptation Requirements + +The code templates in this document use ShaderToy GLSL style. When generating standalone HTML pages, you must adapt for WebGL2: + +- Use `canvas.getContext("webgl2")` +- First line of shaders: `#version 300 es`, add `precision highp float;` in fragment shaders +- Vertex shader: `attribute` -> `in`, `varying` -> `out` +- Fragment shader: `varying` -> `in`, `gl_FragColor` -> custom output variable (must be declared before `void main()`, e.g. `out vec4 outColor;`), `texture2D()` -> `texture()` +- ShaderToy's `void mainImage(out vec4 fragColor, in vec2 fragCoord)` must be adapted to the standard `void main()` entry point + +# CSG Boolean Operations + +## Core Principles + +CSG boolean operations are per-point value operations on two distance fields: + +| Operation | Expression | Meaning | +|-----------|-----------|---------| +| Union | `min(d1, d2)` | Take nearest surface, keeping both shapes | +| Intersection | `max(d1, d2)` | Take farthest surface, keeping only the overlap | +| Subtraction | `max(d1, -d2)` | Cut d1 using the interior of d2 | + +**Smooth booleans** (smooth min/max) introduce a blending band in the transition region. The parameter `k` controls the blend band width (larger = rounder, `k=0` degenerates to hard boolean). Multiple variants exist with different mathematical properties. + +## Implementation Steps + +### Step 1: Hard Boolean Operations + +```glsl +float opUnion(float d1, float d2) { return min(d1, d2); } +float opIntersection(float d1, float d2) { return max(d1, d2); } +float opSubtraction(float d1, float d2) { return max(d1, -d2); } +``` + +### Step 2: Smooth Union (Polynomial Version) + +```glsl +// k: blend radius, typical values 0.05~0.5 +float opSmoothUnion(float d1, float d2, float k) { + float h = clamp(0.5 + 0.5 * (d2 - d1) / k, 0.0, 1.0); + return mix(d2, d1, h) - k * h * (1.0 - h); +} +``` + +### Step 3: Smooth Subtraction and Intersection (Polynomial Version) + +```glsl +float opSmoothSubtraction(float d1, float d2, float k) { + float h = clamp(0.5 - 0.5 * (d2 + d1) / k, 0.0, 1.0); + return mix(d2, -d1, h) + k * h * (1.0 - h); +} + +float opSmoothIntersection(float d1, float d2, float k) { + float h = clamp(0.5 - 0.5 * (d2 - d1) / k, 0.0, 1.0); + return mix(d2, d1, h) + k * h * (1.0 - h); +} +``` + +### Step 4: Quadratic Optimized Version (Recommended as Default) + +```glsl +float smin(float a, float b, float k) { + float h = max(k - abs(a - b), 0.0); + return min(a, b) - h * h * 0.25 / k; +} + +float smax(float a, float b, float k) { + float h = max(k - abs(a - b), 0.0); + return max(a, b) + h * h * 0.25 / k; +} + +// Subtraction via smax +float sSub(float d1, float d2, float k) { + return smax(d1, -d2, k); +} +``` + +### Step 4b: Smooth Minimum Variant Library + +Different smin implementations have different mathematical properties. Choose based on your needs: + +| Variant | Rigid | Associative | Best For | +|---------|-------|-------------|----------| +| Quadratic (default above) | Yes | No | General use, fastest | +| Cubic | Yes | No | Smoother C2 transitions | +| Quartic | Yes | No | Highest quality blending | +| Exponential | No | Yes | Multi-body blending (order-independent) | +| Circular Geometric | Yes | Yes | Strict local blending | + +**Rigid**: preserves original SDF shape outside the blend region (no under-estimation). +**Associative**: `smin(a, smin(b, c))` == `smin(smin(a, b), c)` — important when blending many objects where evaluation order varies. + +```glsl +// --- Cubic Polynomial smin (C2 continuous, smoother transitions) --- +float sminCubic(float a, float b, float k) { + k *= 6.0; + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * h * k * (1.0 / 6.0); +} + +// --- Quartic Polynomial smin (C3 continuous, highest quality) --- +float sminQuartic(float a, float b, float k) { + k *= 16.0 / 3.0; + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * h * (4.0 - h) * k * (1.0 / 16.0); +} + +// --- Exponential smin (associative — order independent for multi-body blending) --- +float sminExp(float a, float b, float k) { + float r = exp2(-a / k) + exp2(-b / k); + return -k * log2(r); +} + +// --- Circular Geometric smin (rigid + local + associative) --- +float sminCircle(float a, float b, float k) { + k *= 1.0 / (1.0 - sqrt(0.5)); + return max(k, min(a, b)) - length(max(k - vec2(a, b), 0.0)); +} + +// --- Gradient-aware smin (carries material/color through blending) --- +// x = distance, yzw = material properties or color components +vec4 sminColor(vec4 a, vec4 b, float k) { + k *= 4.0; + float h = max(k - abs(a.x - b.x), 0.0) / (2.0 * k); + return vec4( + min(a.x, b.x) - h * h * k, + mix(a.yzw, b.yzw, (a.x < b.x) ? h : 1.0 - h) + ); +} + +// --- Smooth maximum from any smin variant --- +// smax(a, b, k) = -smin(-a, -b, k) +// Smooth subtraction: smax(d1, -d2, k) +// Smooth intersection: smax(d1, d2, k) +``` + +### Step 5: Basic SDF Primitives + +```glsl +float sdSphere(vec3 p, float r) { + return length(p) - r; +} + +float sdBox(vec3 p, vec3 b) { + vec3 d = abs(p) - b; + return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0); +} + +float sdCylinder(vec3 p, float h, float r) { + vec2 d = abs(vec2(length(p.xz), p.y)) - vec2(r, h); + return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)); +} +``` + +### Step 6: CSG Composition for Scene Building + +```glsl +float mapScene(vec3 p) { + float cube = sdBox(p, vec3(1.0)); + float sphere = sdSphere(p, 1.2); + float cylX = sdCylinder(p.yzx, 2.0, 0.4); + float cylY = sdCylinder(p.xyz, 2.0, 0.4); + float cylZ = sdCylinder(p.zxy, 2.0, 0.4); + + // (cube intersect sphere) - three cylinders = nut + float shape = opIntersection(cube, sphere); + float holes = opUnion(cylX, opUnion(cylY, cylZ)); + return opSubtraction(shape, holes); +} +``` + +### Step 7: Smooth CSG Modeling for Organic Forms + +```glsl +// Use different k values for different body parts: large k for major joints, small k for fine details +float mapCreature(vec3 p) { + float body = sdSphere(p, 0.5); + float head = sdSphere(p - vec3(0.0, 0.6, 0.3), 0.25); + float d = smin(body, head, 0.15); // large blend + + float leg = sdCylinder(p - vec3(0.2, -0.5, 0.0), 0.3, 0.08); + d = smin(d, leg, 0.08); // medium blend + + float eye = sdSphere(p - vec3(0.05, 0.75, 0.4), 0.05); + d = smax(d, -eye, 0.02); // small blend for subtraction + return d; +} +``` + +### Step 8: Ray Marching Main Loop + +```glsl +float rayMarch(vec3 ro, vec3 rd, float maxDist) { + float t = 0.0; + for (int i = 0; i < MAX_STEPS; i++) { + vec3 p = ro + rd * t; + float d = mapScene(p); + if (d < SURF_DIST) return t; + t += d; + if (t > maxDist) break; + } + return -1.0; +} +``` + +### Step 9: Normal Calculation (Tetrahedral Sampling, 4 Samples More Efficient Than 6 with Central Differences) + +```glsl +vec3 calcNormal(vec3 pos) { + vec2 e = vec2(0.001, -0.001); + return normalize( + e.xyy * mapScene(pos + e.xyy) + + e.yyx * mapScene(pos + e.yyx) + + e.yxy * mapScene(pos + e.yxy) + + e.xxx * mapScene(pos + e.xxx) + ); +} +``` + +## Full Code Template + +```glsl +// === CSG Boolean Operations - WebGL2 Full Template === +// Note: When generating HTML with this template, pass iTime, iResolution, etc. via uniforms + +#define MAX_STEPS 128 +#define MAX_DIST 50.0 +#define SURF_DIST 0.001 +#define SMOOTH_K 0.1 + +// === Hard Boolean Operations === +float opUnion(float d1, float d2) { return min(d1, d2); } +float opIntersection(float d1, float d2) { return max(d1, d2); } +float opSubtraction(float d1, float d2) { return max(d1, -d2); } + +// === Smooth Boolean Operations (Quadratic Optimized) === +float smin(float a, float b, float k) { + float h = max(k - abs(a - b), 0.0); + return min(a, b) - h * h * 0.25 / k; +} + +float smax(float a, float b, float k) { + float h = max(k - abs(a - b), 0.0); + return max(a, b) + h * h * 0.25 / k; +} + +// === SDF Primitives === +float sdSphere(vec3 p, float r) { + return length(p) - r; +} + +float sdBox(vec3 p, vec3 b) { + vec3 d = abs(p) - b; + return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0); +} + +float sdCylinder(vec3 p, float h, float r) { + vec2 d = abs(vec2(length(p.xz), p.y)) - vec2(r, h); + return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)); +} + +float sdEllipsoid(vec3 p, vec3 r) { + float k0 = length(p / r); + float k1 = length(p / (r * r)); + return k0 * (k0 - 1.0) / k1; +} + +float sdCapsule(vec3 p, vec3 a, vec3 b, float r) { + vec3 pa = p - a, ba = b - a; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + return length(pa - ba * h) - r; +} + +// === Scene Definition === +float mapScene(vec3 p) { + // Rotation animation + float angle = iTime * 0.3; + float c = cos(angle), s = sin(angle); + p.xz = mat2(c, -s, s, c) * p.xz; + + // Primitives + float cube = sdBox(p, vec3(1.0)); + float sphere = sdSphere(p, 1.25); + float cylR = 0.45; + float cylX = sdCylinder(p.yzx, 2.0, cylR); + float cylY = sdCylinder(p.xyz, 2.0, cylR); + float cylZ = sdCylinder(p.zxy, 2.0, cylR); + + // Hard boolean combination: nut = (cube intersect sphere) - three cylinders + float nut = opSubtraction( + opIntersection(cube, sphere), + opUnion(cylX, opUnion(cylY, cylZ)) + ); + + // Organic spheres -- smooth union blending + float blob1 = sdSphere(p - vec3(1.8, 0.0, 0.0), 0.4); + float blob2 = sdSphere(p - vec3(-1.8, 0.0, 0.0), 0.4); + float blob3 = sdSphere(p - vec3(0.0, 1.8, 0.0), 0.4); + float blobs = smin(blob1, smin(blob2, blob3, 0.3), 0.3); + + return smin(nut, blobs, 0.15); +} + +// === Normal Calculation (Tetrahedral Sampling) === +vec3 calcNormal(vec3 pos) { + vec2 e = vec2(0.001, -0.001); + return normalize( + e.xyy * mapScene(pos + e.xyy) + + e.yyx * mapScene(pos + e.yyx) + + e.yxy * mapScene(pos + e.yxy) + + e.xxx * mapScene(pos + e.xxx) + ); +} + +// === Ray Marching === +float rayMarch(vec3 ro, vec3 rd) { + float t = 0.0; + for (int i = 0; i < MAX_STEPS; i++) { + vec3 p = ro + rd * t; + float d = mapScene(p); + if (d < SURF_DIST) return t; + t += d; + if (t > MAX_DIST) break; + } + return -1.0; +} + +// === Soft Shadows === +float calcSoftShadow(vec3 ro, vec3 rd, float k) { + float res = 1.0; + float t = 0.02; + for (int i = 0; i < 64; i++) { + float h = mapScene(ro + rd * t); + res = min(res, k * h / t); + t += clamp(h, 0.01, 0.2); + if (res < 0.001 || t > 20.0) break; + } + return clamp(res, 0.0, 1.0); +} + +// === AO (Ambient Occlusion) === +float calcAO(vec3 pos, vec3 nor) { + float occ = 0.0; + float sca = 1.0; + for (int i = 0; i < 5; i++) { + float h = 0.01 + 0.12 * float(i); + float d = mapScene(pos + h * nor); + occ += (h - d) * sca; + sca *= 0.95; + } + return clamp(1.0 - 3.0 * occ, 0.0, 1.0); +} + +// === Main Function (WebGL2 Adapted) === +out vec4 outColor; +void main() { + vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y; + + // Camera + float camDist = 4.0; + float camAngle = 0.3; + vec3 ro = vec3( + camDist * cos(iTime * 0.2), + camDist * sin(camAngle), + camDist * sin(iTime * 0.2) + ); + vec3 ta = vec3(0.0, 0.0, 0.0); + + // Camera matrix + vec3 ww = normalize(ta - ro); + vec3 uu = normalize(cross(ww, vec3(0.0, 1.0, 0.0))); + vec3 vv = cross(uu, ww); + vec3 rd = normalize(uv.x * uu + uv.y * vv + 2.0 * ww); + + // Background color + vec3 col = vec3(0.4, 0.5, 0.6) - 0.3 * rd.y; + + // Ray marching + float t = rayMarch(ro, rd); + if (t > 0.0) { + vec3 pos = ro + rd * t; + vec3 nor = calcNormal(pos); + + vec3 lightDir = normalize(vec3(0.8, 0.6, -0.3)); + float dif = clamp(dot(nor, lightDir), 0.0, 1.0); + float sha = calcSoftShadow(pos + nor * 0.01, lightDir, 16.0); + float ao = calcAO(pos, nor); + float amb = 0.5 + 0.5 * nor.y; + + vec3 mate = vec3(0.2, 0.3, 0.4); + col = vec3(0.0); + col += mate * 2.0 * dif * sha; + col += mate * 0.3 * amb * ao; + } + + col = pow(col, vec3(0.4545)); + outColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Variant 1: Exponential Smooth Union + +```glsl +float sminExp(float a, float b, float k) { + float res = exp(-k * a) + exp(-k * b); + return -log(res) / k; +} +``` + +### Variant 2: Smooth Operations with Color Blending + +```glsl +// Returns blend factor for the caller to blend colors +float sminWithFactor(float a, float b, float k, out float blend) { + float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); + blend = h; + return mix(b, a, h) - k * h * (1.0 - h); +} +// float blend; +// float d = sminWithFactor(d1, d2, 0.1, blend); +// vec3 color = mix(color2, color1, blend); + +// vec3 overload of smax +vec3 smax(vec3 a, vec3 b, float k) { + vec3 h = max(k - abs(a - b), 0.0); + return max(a, b) + h * h * 0.25 / k; +} +``` + +### Variant 3: Stepwise CSG Modeling (Architectural/Industrial) + +```glsl +float sdBuilding(vec3 p) { + float walls = sdBox(p, vec3(1.0, 0.8, 1.0)); + vec3 roofP = p; + roofP.y -= 0.8; + float roof = sdBox(roofP, vec3(1.2, 0.3, 1.2)); + float d = opUnion(walls, roof); + + // Cut windows (exploiting symmetry) + vec3 winP = abs(p); + winP -= vec3(1.01, 0.3, 0.4); + float window = sdBox(winP, vec3(0.1, 0.15, 0.12)); + d = opSubtraction(d, window); + + // Hollow out interior + float hollow = sdBox(p, vec3(0.95, 0.75, 0.95)); + d = opSubtraction(d, hollow); + return d; +} +``` + +### Variant 4: Large-Scale Organic Character Modeling + +```glsl +float mapCharacter(vec3 p) { + float body = sdEllipsoid(p, vec3(0.5, 0.4, 0.6)); + float head = sdEllipsoid(p - vec3(0.0, 0.5, 0.5), vec3(0.25)); + float d = smin(body, head, 0.2); // large k: wide blend + + float ear = sdEllipsoid(p - vec3(0.3, 0.6, 0.3), vec3(0.15, 0.2, 0.05)); + d = smin(d, ear, 0.08); // medium blend + + float nostril = sdSphere(p - vec3(0.0, 0.4, 0.7), 0.03); + d = smax(d, -nostril, 0.02); // small k: fine sculpting + return d; +} +``` + +## Performance & Composition Tips + +**Performance:** +- Bounding volume acceleration: use AABB/bounding spheres to skip distant sub-scenes, reducing `mapScene()` calls +- Tetrahedral sampling normals (4 samples) outperform central differences (6 samples) +- Step scaling `t += d * 0.9` can reduce overshoot penetration +- Prefer quadratic optimized smin/smax (fastest); use exponential version when extreme smoothness is needed +- `k` must not be zero (division by zero error); fall back to hard boolean when near zero +- For symmetric shapes, use `abs()` to fold coordinates and define only one side + +**Composition techniques:** +- **+ Domain Repetition**: `mod()`/`fract()` for infinite repetition of CSG shapes (mechanical arrays, railings) +- **+ Procedural Displacement**: overlay noise displacement on SDF for surface detail +- **+ Procedural Texturing**: use smin blend factor to simultaneously blend material ID / color +- **+ 2D SDF**: equally applicable to 2D scenes (clouds, UI shape compositing) +- **+ Animation**: bind k values, positions, and radii to `iTime` for dynamic deformation + +## Further Reading + +Full step-by-step tutorials, mathematical derivations, and advanced usage in [reference](../reference/csg-boolean-operations.md) diff --git a/skills/shader-dev/techniques/domain-repetition.md b/skills/shader-dev/techniques/domain-repetition.md new file mode 100644 index 0000000..f11e1e6 --- /dev/null +++ b/skills/shader-dev/techniques/domain-repetition.md @@ -0,0 +1,333 @@ +# Domain Repetition & Space Folding + +## Use Cases + +- **Infinite repeating scenes**: render infinitely extending geometry from a single SDF primitive (corridors, cities, star fields) +- **Kaleidoscope/symmetry effects**: N-fold rotational symmetry, mirror symmetry, polyhedral symmetry +- **Fractal geometry**: generate self-similar structures through iterative space folding (Apollonian, Kali-set) +- **Architectural/mechanical structures**: build complex yet regular scenes using repetition + variation +- **Spiral/toroidal topology**: repeat geometry along polar or spiral paths + +Core value: **define geometry in a single cell, render infinite space**. + +## Core Principles + +The essence of domain repetition is **coordinate transformation**: before computing the SDF, fold/map point `p` into a finite "fundamental domain". + +**Three fundamental operations:** + +| Operation | Formula | Effect | +|-----------|---------|--------| +| **mod repetition** | `p = mod(p + c/2, c) - c/2` | Infinite translational repetition along an axis | +| **abs mirroring** | `p = abs(p)` | Mirror symmetry across an axis plane | +| **Rotational folding** | `angle = mod(atan(p.y,p.x), TAU/N)` | N-fold rotational symmetry | + +Key math: `mod(x,c)` -> periodic mapping to `[0,c)`; `abs(x)` -> reflection symmetry; `fract(x)` = `mod(x,1.0)` -> normalized period. + +## Implementation Steps + +### Step 1: Cartesian Domain Repetition (mod repetition) + +```glsl +// Infinite translational repetition along one or more axes +vec3 domainRepeat(vec3 p, vec3 period) { + return mod(p + period * 0.5, period) - period * 0.5; +} + +float map(vec3 p) { + vec3 q = domainRepeat(p, vec3(4.0)); // repeat every 4 units + return sdBox(q, vec3(0.5)); +} +``` + +### Step 2: Symmetric Folding (abs-mod triangle wave) + +```glsl +// Boundary-continuous symmetric folding, coordinates oscillate 0->tile->0 +vec3 symmetricFold(vec3 p, float tile) { + return abs(vec3(tile) - mod(p, vec3(tile * 2.0))); +} + +// Star Nest classic usage +p = abs(vec3(tile) - mod(p, vec3(tile * 2.0))); +``` + +### Step 3: Angular Domain Repetition (Polar Coordinate Folding) + +```glsl +// N-way rotational symmetry (kaleidoscope) +vec2 pmod(vec2 p, float count) { + float angle = atan(p.x, p.y) + PI / count; + float sector = TAU / count; + angle = floor(angle / sector) * sector; + return p * rot(-angle); +} + +p1.xy = pmod(p1.xy, 5.0); // 5-fold symmetry +``` + +### Step 4: fract Domain Folding (Fractal Iteration) + +```glsl +// Apollonian fractal core loop +float map(vec3 p, float s) { + float scale = 1.0; + vec4 orb = vec4(1000.0); + + for (int i = 0; i < 8; i++) { + p = -1.0 + 2.0 * fract(0.5 * p + 0.5); // centered fract folding + float r2 = dot(p, p); + orb = min(orb, vec4(abs(p), r2)); + float k = s / r2; // spherical inversion scaling + p *= k; + scale *= k; + } + return 0.25 * abs(p.y) / scale; +} +``` + +### Step 5: Iterative abs Folding (IFS / Kali-set) + +```glsl +// IFS abs folding fractal +float ifsBox(vec3 p) { + for (int i = 0; i < 5; i++) { + p = abs(p) - 1.0; + p.xy *= rot(iTime * 0.3); + p.xz *= rot(iTime * 0.1); + } + return sdBox(p, vec3(0.4, 0.8, 0.3)); +} + +// Kali-set variant: mod repetition + IFS + dot(p,p) scaling +vec2 de(vec3 pos) { + vec3 tpos = pos; + tpos.xz = abs(0.5 - mod(tpos.xz, 1.0)); + vec4 p = vec4(tpos, 1.0); // w tracks scaling + for (int i = 0; i < 7; i++) { + p.xyz = abs(p.xyz) - vec3(-0.02, 1.98, -0.02); + p = p * (2.0) / clamp(dot(p.xyz, p.xyz), 0.4, 1.0) + - vec4(0.5, 1.0, 0.4, 0.0); + p.xz *= rot(0.416); + } + return vec2(length(max(abs(p.xyz)-vec3(0.1,5.0,0.1), 0.0)) / p.w, 0.0); +} +``` + +### Step 6: Reflection Folding (Polyhedral Symmetry) + +```glsl +// Plane reflection +float pReflect(inout vec3 p, vec3 planeNormal, float offset) { + float t = dot(p, planeNormal) + offset; + if (t < 0.0) p = p - (2.0 * t) * planeNormal; + return sign(t); +} + +// Icosahedral folding +void pModIcosahedron(inout vec3 p) { + vec3 nc = vec3(-0.5, -cos(PI/5.0), sqrt(0.75 - cos(PI/5.0)*cos(PI/5.0))); + p = abs(p); + pReflect(p, nc, 0.0); + p.xy = abs(p.xy); + pReflect(p, nc, 0.0); + p.xy = abs(p.xy); + pReflect(p, nc, 0.0); +} +``` + +### Step 7: Toroidal/Cylindrical Domain Warping + +```glsl +// Bend the xz plane into a toroidal topology +vec2 displaceLoop(vec2 p, float radius) { + return vec2(length(p) - radius, atan(p.y, p.x)); +} + +pDonut.xz = displaceLoop(pDonut.xz, donutRadius); +pDonut.z *= donutRadius; // unfold angle to linear length +``` + +### Step 8: 1D Centered Domain Repetition (with Cell ID) + +```glsl +// Returns cell index, usable for random variations +float pMod1(inout float p, float size) { + float halfsize = size * 0.5; + float c = floor((p + halfsize) / size); + p = mod(p + halfsize, size) - halfsize; + return c; +} + +float cellID = pMod1(p.x, 2.0); +float salt = fract(sin(cellID * 127.1) * 43758.5453); +``` + +## Full Code Template + +Combined demo: Cartesian repetition + angular repetition + IFS folding. Runs directly in ShaderToy. + +```glsl +#define PI 3.14159265359 +#define TAU 6.28318530718 +#define MAX_STEPS 100 +#define MAX_DIST 50.0 +#define SURF_DIST 0.001 +#define PERIOD 4.0 +#define ANGULAR_COUNT 6.0 +#define IFS_ITERS 5 +#define IFS_OFFSET 1.2 + +mat2 rot(float a) { + float c = cos(a), s = sin(a); + return mat2(c, s, -s, c); +} + +float sdBox(vec3 p, vec3 b) { + vec3 d = abs(p) - b; + return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0); +} + +vec3 domainRepeat(vec3 p, vec3 period) { + return mod(p + period * 0.5, period) - period * 0.5; +} + +vec2 pmod(vec2 p, float count) { + float a = atan(p.x, p.y) + PI / count; + float n = TAU / count; + a = floor(a / n) * n; + return p * rot(-a); +} + +float map(vec3 p) { + vec3 q = domainRepeat(p, vec3(PERIOD)); + q.xz = pmod(q.xz, ANGULAR_COUNT); + for (int i = 0; i < IFS_ITERS; i++) { + q = abs(q) - IFS_OFFSET; + q.xy *= rot(0.785); + q.yz *= rot(0.471); + } + return sdBox(q, vec3(0.15, 0.4, 0.15)); +} + +vec3 calcNormal(vec3 p) { + vec2 e = vec2(0.001, 0.0); + return normalize(vec3( + map(p + e.xyy) - map(p - e.xyy), + map(p + e.yxy) - map(p - e.yxy), + map(p + e.yyx) - map(p - e.yyx) + )); +} + +float raymarch(vec3 ro, vec3 rd) { + float t = 0.0; + for (int i = 0; i < MAX_STEPS; i++) { + float d = map(ro + rd * t); + if (d < SURF_DIST || t > MAX_DIST) break; + t += d; + } + return t; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y; + + float time = iTime * 0.5; + vec3 ro = vec3(sin(time) * 6.0, 3.0 + sin(time * 0.7) * 2.0, cos(time) * 6.0); + vec3 ta = vec3(0.0); + vec3 ww = normalize(ta - ro); + vec3 uu = normalize(cross(ww, vec3(0.0, 1.0, 0.0))); + vec3 vv = cross(uu, ww); + vec3 rd = normalize(uv.x * uu + uv.y * vv + 1.8 * ww); + + float t = raymarch(ro, rd); + + vec3 col = vec3(0.0); + if (t < MAX_DIST) { + vec3 p = ro + rd * t; + vec3 n = calcNormal(p); + vec3 lightDir = normalize(vec3(0.5, 0.8, -0.6)); + float diff = clamp(dot(n, lightDir), 0.0, 1.0); + float amb = 0.5 + 0.5 * n.y; + vec3 baseColor = 0.5 + 0.5 * cos(p * 0.5 + vec3(0.0, 2.0, 4.0)); + col = baseColor * (0.2 * amb + 0.8 * diff); + col *= exp(-0.03 * t * t); + } + + col = pow(col, vec3(0.4545)); + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### 1. Volumetric Light/Glow Rendering + +```glsl +float acc = 0.0, t = 0.0; +for (int i = 0; i < 99; i++) { + float dist = map(ro + rd * t); + dist = max(abs(dist), 0.02); + acc += exp(-dist * 3.0); // decay factor controls glow sharpness + t += dist * 0.5; // step scale <1 for denser sampling +} +vec3 col = vec3(acc * 0.01, acc * 0.011, acc * 0.012); +``` + +### 2. Single-Axis/Dual-Axis Selective Repetition + +```glsl +q.xz = mod(q.xz + 2.0, 4.0) - 2.0; // repeat only xz, y stays unchanged +``` + +### 3. Fractal fract Domain Folding (Apollonian Type) + +```glsl +float scale = 1.0; +for (int i = 0; i < 8; i++) { + p = -1.0 + 2.0 * fract(0.5 * p + 0.5); + float k = 1.2 / dot(p, p); + p *= k; + scale *= k; +} +return 0.25 * abs(p.y) / scale; +``` + +### 4. Multi-Layer Nested Repetition + +```glsl +float indexX = amod(p.xz, segments); // outer layer: angular repetition +p.x -= radius; +p.y = repeat(p.y, cellSize); // inner layer: linear repetition +float salt = rng(vec2(indexX, floor(p.y / cellSize))); +``` + +### 5. Finite Domain Repetition (Clamp Limited) + +```glsl +vec3 domainRepeatLimited(vec3 p, float size, vec3 limit) { + return p - size * clamp(floor(p / size + 0.5), -limit, limit); +} +// Repeat 5 times along x, 3 times along y/z +vec3 q = domainRepeatLimited(p, 2.0, vec3(2.0, 1.0, 1.0)); +``` + +## Performance & Composition Tips + +**Performance:** +- 5-8 fractal iterations are typically sufficient; use `vec4.w` to track scaling and avoid extra variables +- Ensure geometry radius < period/2 to prevent inaccurate SDF at cell boundaries +- Volumetric light step size should increase with distance: `t += dist * (0.3 + t * 0.02)` +- Use `clamp(dot(p,p), min, max)` to prevent numerical explosion +- Avoid `normalize()` inside loops; manually divide by length instead + +**Composition:** +- **Domain Repetition + Ray Marching**: the most fundamental combination, used by all reference shaders +- **Domain Repetition + Orbit Trap Coloring**: record `min(orb, abs(p))` during fractal iteration for coloring +- **Domain Repetition + Toroidal Warping**: `displaceLoop` to bend space before applying linear/angular repetition +- **Domain Repetition + Noise Variation**: cell ID -> pseudo-random number -> modulate geometry parameters +- **Domain Repetition + Polar Spiral**: `cartToPolar` combined with `pMod1` for spiral path repetition + +## Further Reading + +Full step-by-step tutorials, mathematical derivations, and advanced usage in [reference](../reference/domain-repetition.md) diff --git a/skills/shader-dev/techniques/domain-warping.md b/skills/shader-dev/techniques/domain-warping.md new file mode 100644 index 0000000..b2de5d3 --- /dev/null +++ b/skills/shader-dev/techniques/domain-warping.md @@ -0,0 +1,414 @@ +# Domain Warping + +## Use Cases + +- **Marble/jade textures**: multi-layer warping produces streaked stone textures +- **Fabric/silk appearance**: warping field creases simulate textile surfaces +- **Geological formations**: rock strata, lava flows, surface erosion +- **Gas giant atmospheres**: Jupiter-style banded circulation +- **Smoke/fire/explosions**: fluid effects combined with volumetric rendering +- **Abstract art backgrounds**: procedural organic patterns, suitable for UI backgrounds, music visualization +- **Electric current/plasma effects**: ridged FBM variant produces sharp arc patterns + +Core advantage: relies only on math functions (no texture assets needed), outputs seamless tiling, animatable, GPU-friendly. + +## Core Principles + +Warp input coordinates with noise, then query the main function: + +``` +f(p) -> f(p + fbm(p)) +``` + +Classic multi-layer recursive nesting: + +``` +result = fbm(p + fbm(p + fbm(p))) +``` + +Each FBM layer's output serves as a coordinate offset for the next layer; deeper nesting produces more organic deformation. + +**Key mathematical structure**: + +1. **Noise** `noise(p)`: pseudo-random values at integer lattice points + Hermite interpolation `f*f*(3.0-2.0*f)` +2. **FBM**: `fbm(p) = sum of (0.5^i) * noise(p * 2^i * R^i)`, where `R` is a rotation matrix for decorrelation +3. **Domain warping chain**: `fbm(p + fbm(p + fbm(p)))` + +The rotation matrix `mat2(0.80, 0.60, -0.60, 0.80)` (approx 36.87 deg) is the most widely used decorrelation transform. + +## Implementation Steps + +### Step 1: Hash Function + +```glsl +// Map 2D integer coordinates to a pseudo-random float +float hash(vec2 p) { + p = fract(p * 0.6180339887); // golden ratio pre-perturbation + p *= 25.0; + return fract(p.x * p.y * (p.x + p.y)); +} +``` + +> The classic `fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453)` also works; the sin-free version above has more stable precision on some GPUs. + +### Step 2: Value Noise + +```glsl +// Hash values at integer lattice points, Hermite smooth interpolation +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + + return mix( + mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), f.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), + f.y + ); +} +``` + +### Step 3: FBM + +```glsl +const mat2 mtx = mat2(0.80, 0.60, -0.60, 0.80); // rotation approx 36.87 deg + +float fbm(vec2 p) { + float f = 0.0; + f += 0.500000 * noise(p); p = mtx * p * 2.02; + f += 0.250000 * noise(p); p = mtx * p * 2.03; + f += 0.125000 * noise(p); p = mtx * p * 2.01; + f += 0.062500 * noise(p); p = mtx * p * 2.04; + f += 0.031250 * noise(p); p = mtx * p * 2.01; + f += 0.015625 * noise(p); + return f / 0.96875; +} +``` + +> Lacunarity uses 2.01~2.04 rather than exactly 2.0 to avoid visual artifacts caused by lattice regularity. + +### Step 4: Domain Warping (Core) + +```glsl +// Classic three-layer domain warping +float pattern(vec2 p) { + return fbm(p + fbm(p + fbm(p))); +} +``` + +### Step 5: Time Animation + +```glsl +// Inject time into the first and last octaves: low frequency drives overall flow, high frequency adds detail variation +float fbm(vec2 p) { + float f = 0.0; + f += 0.500000 * noise(p + iTime); // lowest frequency: slow overall flow + p = mtx * p * 2.02; + f += 0.250000 * noise(p); p = mtx * p * 2.03; + f += 0.125000 * noise(p); p = mtx * p * 2.01; + f += 0.062500 * noise(p); p = mtx * p * 2.04; + f += 0.031250 * noise(p); p = mtx * p * 2.01; + f += 0.015625 * noise(p + sin(iTime)); // highest frequency: subtle detail motion + return f / 0.96875; +} +``` + +### Step 6: Coloring + +```glsl +// Map scalar field (0~1) to color using a mix chain +// IMPORTANT: Note: GLSL is strictly typed. Variable declarations must be complete, e.g. vec3 col = vec3(0.2, 0.1, 0.4) +// IMPORTANT: Decimals must be written as 0.x, not .x (division by zero errors) +vec3 palette(float t) { + vec3 col = vec3(0.2, 0.1, 0.4); // deep purple base + col = mix(col, vec3(0.3, 0.05, 0.05), t); // dark red + col = mix(col, vec3(0.9, 0.9, 0.9), t * t); // high values toward white + col = mix(col, vec3(0.0, 0.2, 0.4), smoothstep(0.6, 0.8, t)); // blue highlight + return col * t * 2.0; +} +``` + +## Full Code Template + +```glsl +// Domain Warping — Full Runnable Template (ShaderToy) + +#define WARP_DEPTH 3 // Warp nesting depth (1=subtle, 2=moderate, 3=classic) +#define NUM_OCTAVES 6 // FBM octave count (4=coarse fast, 6=fine) +#define TIME_SCALE 1.0 // Animation speed (0.05=very slow, 1.0=fluid, 2.0=fast) +#define WARP_STRENGTH 1.0 // Warp intensity (0.5=subtle, 1.0=standard, 2.0=strong) +#define BASE_SCALE 3.0 // Overall noise scale (larger = denser texture) + +const mat2 mtx = mat2(0.80, 0.60, -0.60, 0.80); + +float hash(vec2 p) { + p = fract(p * 0.6180339887); + p *= 25.0; + return fract(p.x * p.y * (p.x + p.y)); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + + return mix( + mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), f.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), + f.y + ); +} + +float fbm(vec2 p) { + float f = 0.0; + float amp = 0.5; + float freq = 1.0; + float norm = 0.0; + + for (int i = 0; i < NUM_OCTAVES; i++) { + float t = 0.0; + if (i == 0) t = iTime * TIME_SCALE; + if (i == NUM_OCTAVES - 1) t = sin(iTime * TIME_SCALE); + + f += amp * noise(p + t); + norm += amp; + p = mtx * p * 2.02; + amp *= 0.5; + } + return f / norm; +} + +float pattern(vec2 p) { + float val = fbm(p); + + #if WARP_DEPTH >= 2 + val = fbm(p + WARP_STRENGTH * val); + #endif + + #if WARP_DEPTH >= 3 + val = fbm(p + WARP_STRENGTH * val); + #endif + + return val; +} + +vec3 palette(float t) { + vec3 col = vec3(0.2, 0.1, 0.4); + col = mix(col, vec3(0.3, 0.05, 0.05), t); + col = mix(col, vec3(0.9, 0.9, 0.9), t * t); + col = mix(col, vec3(0.0, 0.2, 0.4), smoothstep(0.6, 0.8, t)); + return col * t * 2.0; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; + uv *= BASE_SCALE; + + float shade = pattern(uv); + vec3 col = palette(shade); + + // Vignette effect + vec2 q = fragCoord / iResolution.xy; + col *= 0.5 + 0.5 * sqrt(16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y)); + + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Variant 1: Multi-Resolution Layered Warping + +Different warp layers use FBM with different octave counts, outputting `vec2` for dual-axis displacement, with intermediate variables used for coloring. + +```glsl +float fbm4(vec2 p) { + float f = 0.0; + f += 0.5000 * (-1.0 + 2.0 * noise(p)); p = mtx * p * 2.02; + f += 0.2500 * (-1.0 + 2.0 * noise(p)); p = mtx * p * 2.03; + f += 0.1250 * (-1.0 + 2.0 * noise(p)); p = mtx * p * 2.01; + f += 0.0625 * (-1.0 + 2.0 * noise(p)); + return f / 0.9375; +} + +float fbm6(vec2 p) { + float f = 0.0; + f += 0.500000 * noise(p); p = mtx * p * 2.02; + f += 0.250000 * noise(p); p = mtx * p * 2.03; + f += 0.125000 * noise(p); p = mtx * p * 2.01; + f += 0.062500 * noise(p); p = mtx * p * 2.04; + f += 0.031250 * noise(p); p = mtx * p * 2.01; + f += 0.015625 * noise(p); + return f / 0.96875; +} + +vec2 fbm4_2(vec2 p) { + return vec2(fbm4(p + vec2(1.0)), fbm4(p + vec2(6.2))); +} +vec2 fbm6_2(vec2 p) { + return vec2(fbm6(p + vec2(9.2)), fbm6(p + vec2(5.7))); +} + +float func(vec2 q, out vec2 o, out vec2 n) { + q += 0.05 * sin(vec2(0.11, 0.13) * iTime + length(q) * 4.0); + o = 0.5 + 0.5 * fbm4_2(q); + o += 0.02 * sin(vec2(0.13, 0.11) * iTime * length(o)); + n = fbm6_2(4.0 * o); + vec2 p = q + 2.0 * n + 1.0; + float f = 0.5 + 0.5 * fbm4(2.0 * p); + f = mix(f, f * f * f * 3.5, f * abs(n.x)); + return f; +} + +// Coloring uses intermediate variables o, n +vec3 col = vec3(0.2, 0.1, 0.4); +col = mix(col, vec3(0.3, 0.05, 0.05), f); +col = mix(col, vec3(0.9, 0.9, 0.9), dot(n, n)); +col = mix(col, vec3(0.5, 0.2, 0.2), 0.5 * o.y * o.y); +col = mix(col, vec3(0.0, 0.2, 0.4), 0.5 * smoothstep(1.2, 1.3, abs(n.y) + abs(n.x))); +col *= f * 2.0; +``` + +### Variant 2: Turbulence/Ridged Warping (Electric Arc/Plasma Effect) + +In FBM, apply `abs(noise - 0.5)` to produce ridged textures, with dual-axis independent displacement + time-reversed drift. + +```glsl +float fbm_ridged(vec2 p) { + float z = 2.0; + float rz = 0.0; + for (float i = 1.0; i < 6.0; i++) { + rz += abs((noise(p) - 0.5) * 2.0) / z; + z *= 2.0; + p *= 2.0; + } + return rz; +} + +float dualfbm(vec2 p) { + vec2 p2 = p * 0.7; + vec2 basis = vec2( + fbm_ridged(p2 - iTime * 0.24), + fbm_ridged(p2 + iTime * 0.26) + ); + basis = (basis - 0.5) * 0.2; + p += basis; + return fbm_ridged(p * makem2(iTime * 0.03)); +} + +// Electric arc coloring +vec3 col = vec3(0.2, 0.1, 0.4) / rz; +``` + +### Variant 3: Pseudo-3D Lit Domain Warping + +Estimate screen-space normals via finite differences, apply directional lighting for an embossed effect. + +```glsl +float e = 2.0 / iResolution.y; +vec3 nor = normalize(vec3( + pattern(p + vec2(e, 0.0)) - shade, + 2.0 * e, + pattern(p + vec2(0.0, e)) - shade +)); + +vec3 lig = normalize(vec3(0.9, 0.2, -0.4)); +float dif = clamp(0.3 + 0.7 * dot(nor, lig), 0.0, 1.0); +vec3 lin = vec3(0.70, 0.90, 0.95) * (nor.y * 0.5 + 0.5); +lin += vec3(0.15, 0.10, 0.05) * dif; + +col *= 1.2 * lin; +col = 1.0 - col; +col = 1.1 * col * col; +``` + +### Variant 4: Flow Field Iterative Warping (Gas Giant Effect) + +Compute the FBM gradient field, Euler-integrate to iteratively advect coordinates, simulating fluid convection vortices. + +```glsl +#define ADVECT_ITERATIONS 5 + +vec2 field(vec2 p) { + float t = 0.2 * iTime; + p.x += t; + float n = fbm(p, t); + float e = 0.25; + float nx = fbm(p + vec2(e, 0.0), t); + float ny = fbm(p + vec2(0.0, e), t); + return vec2(n - ny, nx - n) / e; +} + +vec3 distort(vec2 p) { + for (float i = 0.0; i < float(ADVECT_ITERATIONS); i++) { + p += field(p) / float(ADVECT_ITERATIONS); + } + return vec3(fbm(p, 0.0)); +} +``` + +### Variant 5: 3D Volumetric Domain Warping (Explosion/Fireball Effect) + +Displace a sphere SDF with 3D FBM, rendered via volumetric ray marching. + +```glsl +#define NOISE_FREQ 4.0 +#define NOISE_AMP -0.5 + +mat3 m3 = mat3(0.00, 0.80, 0.60, + -0.80, 0.36,-0.48, + -0.60,-0.48, 0.64); + +float noise3D(vec3 p) { + vec3 fl = floor(p); + vec3 fr = fract(p); + fr = fr * fr * (3.0 - 2.0 * fr); + float n = fl.x + fl.y * 157.0 + 113.0 * fl.z; + return mix(mix(mix(hash(n+0.0), hash(n+1.0), fr.x), + mix(hash(n+157.0), hash(n+158.0), fr.x), fr.y), + mix(mix(hash(n+113.0), hash(n+114.0), fr.x), + mix(hash(n+270.0), hash(n+271.0), fr.x), fr.y), fr.z); +} + +float fbm3D(vec3 p) { + float f = 0.0; + f += 0.5000 * noise3D(p); p = m3 * p * 2.02; + f += 0.2500 * noise3D(p); p = m3 * p * 2.03; + f += 0.1250 * noise3D(p); p = m3 * p * 2.01; + f += 0.0625 * noise3D(p); p = m3 * p * 2.02; + f += 0.03125 * abs(noise3D(p)); + return f / 0.9375; +} + +float distanceFunc(vec3 p, out float displace) { + float d = length(p) - 0.5; + displace = fbm3D(p * NOISE_FREQ + vec3(0, -1, 0) * iTime); + d += displace * NOISE_AMP; + return d; +} +``` + +## Performance & Composition + +### Performance Tips + +- Three warp layers x 6 octaves = 18 noise samples per pixel; adding lit finite differences can reach 54 +- **Reduce octaves**: 4 instead of 6, ~33% performance gain with minimal visual difference +- **Reduce warp depth**: two layers `fbm(p + fbm(p))` is already organic enough, saving ~33% +- **sin-product noise**: `sin(p.x)*sin(p.y)` is branchless and memory-free, suitable for mobile +- **GPU built-in derivatives**: `dFdx/dFdy` instead of finite differences, 3x faster +- **Texture noise**: pre-bake noise textures, trading computation for memory reads +- **LOD adaptive**: reduce octave count for distant pixels +- **Supersampling**: only use 2x2 when anti-aliasing is needed, 4x performance cost + +### Composition Suggestions + +- **Ray marching**: warped scalar field as SDF displacement function -> fire, explosions, organic forms +- **Polar coordinate transform**: domain warping in polar space -> vortices, nebulae, spirals +- **Cosine palette**: `a + b*cos(2*pi*(c*t+d))` is more flexible than mix chains +- **Post-processing**: bloom glow, tone mapping `col/(1+col)`, chromatic aberration (RGB channel offset sampling) +- **Particles/geometry**: scalar field driving particle velocity fields, vertex displacement, UV animation + +## Further Reading + +Full step-by-step tutorials, mathematical derivations, and advanced usage in [reference](../reference/domain-warping.md) diff --git a/skills/shader-dev/techniques/fluid-simulation.md b/skills/shader-dev/techniques/fluid-simulation.md new file mode 100644 index 0000000..18ce1a3 --- /dev/null +++ b/skills/shader-dev/techniques/fluid-simulation.md @@ -0,0 +1,1175 @@ +**IMPORTANT: Common Error When Extracting Shaders from HTML Script Tags**: When extracting source from ` +const source = document.getElementById('fs').textContent; // contains leading whitespace + +// CORRECT: use .trim() or place template string flush with the start +const source = document.getElementById('fs').textContent.trim(); +// Or in HTML, place content directly after the tag: +// +``` + +**Buffer A (Fluid Computation)**: +```glsl +// Grid-Based Euler Fluid Solver — Buffer A +// Data layout: .xy=velocity, .z=pressure/density, .w=ink +// iChannel0 = Buffer A (self-feedback) + +#define DT 0.15 // time step [0.05 - 0.3] +#define K 0.2 // pressure correction strength [0.1 - 0.4] +#define NU 0.5 // viscosity coefficient [0.01=water, 1.0=syrup] +#define KAPPA 0.1 // ink diffusion coefficient [0.0 - 0.5] +#define MOUSE_RAD 50.0 // mouse influence radius [10.0 - 200.0] + +#define T(p) texture(iChannel0, (p) / iResolution.xy) + +void mainImage(out vec4 fragColor, in vec2 p) { + // Initial frames: add slight noise to break symmetry lock + if (iFrame < 10) { + vec2 uv = p / iResolution.xy; + float noise = fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453); + fragColor = vec4(noise * 1e-4, noise * 1e-4, 1.0, 0.0); + return; + } + + vec4 c = T(p); + + vec4 n = T(p + vec2(0, 1)); + vec4 e = T(p + vec2(1, 0)); + vec4 s = T(p - vec2(0, 1)); + vec4 w = T(p - vec2(1, 0)); + + vec4 laplacian = (n + e + s + w - 4.0 * c); + vec4 dx = (e - w) / 2.0; + vec4 dy = (n - s) / 2.0; + float div = dx.x + dy.y; + + c.z -= DT * (dx.z * c.x + dy.z * c.y + div * c.z); + c.xyw = T(p - DT * c.xy).xyw; + c.xyw += DT * vec3(NU, NU, KAPPA) * laplacian.xyw; + c.xy -= K * vec2(dx.z, dy.z); + + // Mouse interaction: iMouse.z is the pressed flag (>0), velocity obtained via iMouseVel uniform + // IMPORTANT: mouseVel must be clamped to prevent NaN explosion (JS side should also clamp — double safety) + if (iMouse.z > 0.0) { + vec2 mouseVel = clamp(iMouseVel, vec2(-50.0), vec2(50.0)); + float dist2 = dot(p - iMouse.xy, p - iMouse.xy); + float influence = exp(-dist2 / MOUSE_RAD); + c.xy += DT * influence * mouseVel; + c.w += DT * influence * 0.5; + } + + // Vorticity confinement: prevents small vortices from dissipating too quickly, producing curling textures + // IMPORTANT: Ink diffusion/swirl effects (e.g., ink diffusing in water) require vorticity confinement, otherwise curl dissipates quickly leaving only smooth flow + float curl_c = dx.y - dy.x; + float curl_n = (T(p + vec2(1,1)).y - T(p + vec2(-1,1)).y) / 2.0 + - (T(p + vec2(0,2)).x - T(p).x) / 2.0; + float curl_s = (T(p + vec2(1,-1)).y - T(p + vec2(-1,-1)).y) / 2.0 + - (T(p).x - T(p + vec2(0,-2)).x) / 2.0; + float curl_e = (T(p + vec2(2,0)).y - T(p).y) / 2.0 + - (T(p + vec2(1,1)).x - T(p + vec2(1,-1)).x) / 2.0; + float curl_w = (T(p).y - T(p + vec2(-2,0)).y) / 2.0 + - (T(p + vec2(-1,1)).x - T(p + vec2(-1,-1)).x) / 2.0; + vec2 eta = vec2(abs(curl_e) - abs(curl_w), abs(curl_n) - abs(curl_s)); + eta = normalize(eta + vec2(1e-5)); + c.xy += DT * 0.035 * vec2(eta.y, -eta.x) * curl_c; + + // IMPORTANT: Automatic ink sources: ensure visible fluid motion without mouse interaction + // Emitter positions must move over time, and Gaussian radius must be small enough to maintain locality + float t = iTime; + vec2 em1 = iResolution.xy * vec2(0.25, 0.5 + 0.2 * sin(t * 0.7)); + vec2 em2 = iResolution.xy * vec2(0.75, 0.5 + 0.2 * cos(t * 0.9)); + vec2 em3 = iResolution.xy * vec2(0.5, 0.3 + 0.15 * sin(t * 1.3)); + + float r1 = exp(-dot(p - em1, p - em1) / 150.0); + float r2 = exp(-dot(p - em2, p - em2) / 150.0); + float r3 = exp(-dot(p - em3, p - em3) / 120.0); + + c.xy += DT * r1 * vec2(cos(t), sin(t * 1.3)) * 3.0; + c.xy += DT * r2 * vec2(-cos(t * 0.8), sin(t * 0.6)) * 3.0; + c.xy += DT * r3 * vec2(sin(t * 1.1), -cos(t)) * 2.0; + c.w += DT * (r1 + r2 + r3) * 2.0; + + // IMPORTANT: Ink decay: must use multiplicative decay, do NOT use subtractive (subtractive causes saturation) + c.w *= 0.99; + + c = clamp(c, vec4(-5, -5, 0.5, 0), vec4(5, 5, 3, 5)); + + if (p.x < 1.0 || p.y < 1.0 || + iResolution.x - p.x < 1.0 || iResolution.y - p.y < 1.0) { + c.xyw *= 0.0; + } + + fragColor = c; +} +``` + +**Image (Visualization Rendering)**: +```glsl +// Fluid Visualization — Image Pass +// iChannel0 = Buffer A + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec4 c = texture(iChannel0, uv); + + // IMPORTANT: Color base must be bright enough! 0.5+0.5*cos produces [0,1] range bright colors + // Never use vec3(0.02, 0.01, 0.08) or similar extremely dark base colors — they become invisible when multiplied by ink + float angle = atan(c.y, c.x); + vec3 col = 0.5 + 0.5 * cos(angle + vec3(0.0, 2.1, 4.2)); + + // IMPORTANT: smoothstep upper limit should cover actual ink range to preserve gradient variation + float ink = smoothstep(0.0, 2.0, c.w); + col *= ink; + + // Pressure highlights + col += vec3(0.05) * clamp(c.z - 1.0, 0.0, 1.0); + + // IMPORTANT: Background color must be visible (RGB at least > 5/255 ≈ 0.02), otherwise users think the page is all black + col = max(col, vec3(0.02, 0.012, 0.035)); + + fragColor = vec4(col, 1.0); +} +``` + +## Water Surface Ripple Complete Template + +Water surface ripples use the wave equation rather than Navier-Stokes. Clicks/touches generate concentric ripples that interfere with each other and gradually decay. + +**IMPORTANT: Water ripple drop injection must be implemented directly in the shader using iMouse**, do not use custom uniform arrays to pass click positions — that adds complexity on both JS/GLSL sides and is error-prone (uniform location not found, array length mismatch, etc.). + +### Water Ripple Buffer Pass (Wave Equation Solver) + +```glsl +// Water Ripple — Buffer Pass (Wave Equation Solver) +// Data encoding: .r = previous frame height (prev), .g = current frame height (curr) +// iChannel0 = self-feedback (ping-pong) +// IMPORTANT: Drop injection is done directly in the shader via iMouse, no custom uniforms needed + +void main() { + vec2 p = gl_FragCoord.xy; + vec2 uv = p / iResolution.xy; + + if (iFrame < 2) { + fragColor = vec4(0.0); + return; + } + + float prev = texture(iChannel0, uv).r; + float curr = texture(iChannel0, uv).g; + + vec2 texel = 1.0 / iResolution.xy; + float n = texture(iChannel0, uv + vec2(0.0, texel.y)).g; + float s = texture(iChannel0, uv - vec2(0.0, texel.y)).g; + float e = texture(iChannel0, uv + vec2(texel.x, 0.0)).g; + float w = texture(iChannel0, uv - vec2(texel.x, 0.0)).g; + + float laplacian = n + s + e + w - 4.0 * curr; + + // Verlet integration: next = 2*curr - prev + c²*laplacian + float speed = 0.45; + float next = 2.0 * curr - prev + speed * laplacian; + + // damping: 0.995~0.998 lets ripples propagate several rings before disappearing + float damping = 0.996; + next *= damping; + + // IMPORTANT: Mouse click drop injection — directly using iMouse, simple and reliable + // iMouse.z > 0 indicates mouse is pressed + if (iMouse.z > 0.0) { + float dist = length(p - iMouse.xy); + float radius = 12.0; + float strength = 1.5; + next += strength * exp(-dist * dist / (2.0 * radius * radius)); + } + + // IMPORTANT: Automatic ripples: ensure visible ripples even without interaction + // Use periodic functions of iTime to control auto-drop position and timing + float autoPhase = iTime * 0.5; + float autoPeriod = fract(autoPhase); + // Only inject during phase < 0.05 each cycle (avoid continuous injection) + if (autoPeriod < 0.05) { + float idx = floor(autoPhase); + // Pseudo-random position + vec2 autoPos = iResolution.xy * vec2( + 0.2 + 0.6 * fract(sin(idx * 12.9898) * 43758.5453), + 0.2 + 0.6 * fract(sin(idx * 78.233) * 43758.5453) + ); + float dist = length(p - autoPos); + next += 1.2 * exp(-dist * dist / (2.0 * 10.0 * 10.0)); + } + + // Boundary absorption + if (p.x < 2.0 || p.y < 2.0 || + iResolution.x - p.x < 2.0 || iResolution.y - p.y < 2.0) { + next *= 0.0; + } + + // IMPORTANT: Output: .r = current frame (becomes next frame's prev), .g = newly computed (becomes next frame's curr) + fragColor = vec4(curr, next, 0.0, 1.0); +} +``` + +### Water Ripple JS Side + +The water ripple JS structure is identical to the fluid simulation skeleton (ping-pong FBO + render loop), with only these differences: +- Buffer pass shader is the wave equation solver (template above) +- Image pass is the water surface lighting renderer (Step 9c) +- **No custom uniform arrays needed**, drop injection is done entirely in the shader via iMouse +- JS side only needs to pass standard uniforms: `iChannel0, iResolution, iTime, iFrame, iMouse` + +When dragging the mouse, ripples are continuously injected (because iMouse.z > 0 remains true), and faster dragging produces denser ripples (a natural effect). + +### Water Ripple Image Pass + +See Step 9c above. + +## Multi-Color Ink Mixing Template (Ink Diffusion in Water / Multi-Color Blending) + +When multiple ink colors need to interpenetrate and blend, a single scalar `c.w` is insufficient. You need **two Buffers**: Buffer A stores velocity/pressure (same as above), Buffer B stores RGB three-channel ink concentration, sharing the same velocity field for advection. + +**IMPORTANT: Key for multi-color ink: Buffer B's RGB channels independently store the concentration of each ink color, using Buffer A's velocity field for semi-Lagrangian advection. Different ink colors naturally blend during advection and diffusion.** + +### JS Side Changes (Three Buffer Ping-Pong) + +Two sets of ping-pong FBOs are needed: `bufA/bufB` (velocity field) and `bufC/bufD` (ink RGB). In the render loop, first render Buffer A (velocity field), then Buffer B (ink advection), and finally the Image pass reads Buffer B for visualization: + +```javascript +// Create additional ink FBO pair +let bufC, bufD; +function resize() { + // ... same as above for bufA/bufB ... + bufC = createFBO(W, H); + bufD = createFBO(W, H); +} + +// Buffer B shader needs two input textures: +// iChannel0 = Buffer B self (ink RGB) +// iChannel1 = Buffer A (velocity field) +const uBufInk = { + ch0: gl.getUniformLocation(progBufInk, 'iChannel0'), + ch1: gl.getUniformLocation(progBufInk, 'iChannel1'), + res: gl.getUniformLocation(progBufInk, 'iResolution'), + time: gl.getUniformLocation(progBufInk, 'iTime'), + frame: gl.getUniformLocation(progBufInk, 'iFrame'), + mouse: gl.getUniformLocation(progBufInk, 'iMouse'), +}; + +function render(t) { + t *= 0.001; + // Pass 1: Buffer A (velocity) — read bufA, write bufB + // ... same as above ... + [bufA, bufB] = [bufB, bufA]; + + // Pass 2: Buffer B (ink RGB) — read bufC(ink)+bufA(velocity), write bufD + gl.useProgram(progBufInk); + gl.bindFramebuffer(gl.FRAMEBUFFER, bufD.fbo); + gl.viewport(0, 0, W, H); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, bufC.tex); // previous frame ink + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, bufA.tex); // current velocity field + gl.uniform1i(uBufInk.ch0, 0); + gl.uniform1i(uBufInk.ch1, 1); + gl.uniform2f(uBufInk.res, W, H); + gl.uniform1f(uBufInk.time, t); + gl.uniform1i(uBufInk.frame, frameCount); + gl.uniform4f(uBufInk.mouse, mouse[0], mouse[1], mouse[2], 0.0); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + [bufC, bufD] = [bufD, bufC]; + + // Pass 3: Image — read bufC(ink) to screen + gl.useProgram(progImg); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, bufC.tex); + // ... +} +``` + +### Buffer B — Multi-Color Ink Advection Shader + +```glsl +// Multi-Color Ink — Buffer B (Ink Advection) +// .rgb = concentrations of three ink colors +// iChannel0 = Buffer B self (ink RGB) +// iChannel1 = Buffer A (velocity field, .xy=velocity) + +#define DT 0.15 +#define INK_KAPPA 0.3 // ink diffusion coefficient (higher than single-color template for faster blending) +#define INK_DECAY 0.995 // ink decay (slower than single-color to maintain richness) + +#define TINK(p) texture(iChannel0, (p) / iResolution.xy) +#define TVEL(p) texture(iChannel1, (p) / iResolution.xy) + +void mainImage(out vec4 fragColor, in vec2 p) { + if (iFrame < 10) { fragColor = vec4(0.0, 0.0, 0.0, 1.0); return; } + + vec2 vel = TVEL(p).xy; + + // Semi-Lagrangian advection: backward trace using velocity field + vec3 ink = TINK(p - DT * vel).rgb; + + // Diffusion: Laplacian operator + vec3 inkC = TINK(p).rgb; + vec3 inkN = TINK(p + vec2(0,1)).rgb; + vec3 inkE = TINK(p + vec2(1,0)).rgb; + vec3 inkS = TINK(p - vec2(0,1)).rgb; + vec3 inkW = TINK(p - vec2(1,0)).rgb; + vec3 lapInk = inkN + inkE + inkS + inkW - 4.0 * inkC; + ink += DT * INK_KAPPA * lapInk; + + // Automatic ink sources: multiple emitters with different colors + float t = iTime; + vec2 em1 = iResolution.xy * vec2(0.25, 0.5 + 0.2 * sin(t * 0.7)); + vec2 em2 = iResolution.xy * vec2(0.75, 0.5 + 0.2 * cos(t * 0.9)); + vec2 em3 = iResolution.xy * vec2(0.5, 0.3 + 0.15 * sin(t * 1.3)); + vec2 em4 = iResolution.xy * vec2(0.5, 0.7 + 0.15 * cos(t * 0.5)); + + float r1 = exp(-dot(p - em1, p - em1) / 200.0); + float r2 = exp(-dot(p - em2, p - em2) / 200.0); + float r3 = exp(-dot(p - em3, p - em3) / 180.0); + float r4 = exp(-dot(p - em4, p - em4) / 180.0); + + // Each emitter injects a different color + ink.r += DT * (r1 * 3.0 + r4 * 1.5); // red/magenta + ink.g += DT * (r2 * 3.0 + r3 * 1.5); // green/cyan + ink.b += DT * (r3 * 3.0 + r1 * 0.8 + r2 * 0.8); // blue/mixed + + // Mouse stirring injects white ink (all channels) + if (iMouse.z > 0.0) { + float dist2 = dot(p - iMouse.xy, p - iMouse.xy); + float influence = exp(-dist2 / 80.0); + ink += vec3(DT * influence * 2.0); + } + + // Decay + clamp + ink *= INK_DECAY; + ink = clamp(ink, vec3(0.0), vec3(5.0)); + + // Boundary clear + if (p.x < 1.0 || p.y < 1.0 || + iResolution.x - p.x < 1.0 || iResolution.y - p.y < 1.0) { + ink = vec3(0.0); + } + + fragColor = vec4(ink, 1.0); +} +``` + +### Step 9d: Visualization (Image Pass) — Multi-Color Ink Mixing + +```glsl +// Multi-Color Ink Visualization — Image Pass +// iChannel0 = Buffer B (ink RGB) + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec3 ink = texture(iChannel0, uv).rgb; + + // Use smoothstep to map each channel, preserving concentration gradients + vec3 mapped = smoothstep(vec3(0.0), vec3(2.5), ink); + + // Color mapping: map RGB concentrations to actual visible colors + // IMPORTANT: Base colors must be bright, do not use extremely dark values + vec3 col1 = vec3(0.9, 0.15, 0.2); // red ink + vec3 col2 = vec3(0.1, 0.8, 0.3); // green ink + vec3 col3 = vec3(0.15, 0.3, 0.95); // blue ink + + vec3 col = col1 * mapped.r + col2 * mapped.g + col3 * mapped.b; + + // Mixing regions produce new hues (additive blending naturally creates gradients) + // HDR tone mapping to prevent overexposure + col = 1.0 - exp(-col * 1.2); + + // Background color + float totalInk = mapped.r + mapped.g + mapped.b; + vec3 bg = vec3(0.02, 0.015, 0.04); + col = mix(bg, col, smoothstep(0.0, 0.3, totalInk)); + + fragColor = vec4(col, 1.0); +} +``` + +**IMPORTANT: The multi-color ink Buffer A velocity field template is identical to the single-color version**, except `c.w` is no longer used for ink (ink is in Buffer B). Buffer A only handles velocity + pressure. + +## Common Variants + +### Variant 1: Rotational Self-Advection +Does not use pressure projection; achieves naturally divergence-free advection through multi-scale rotational sampling. +```glsl +#define RotNum 3 +#define angRnd 1.0 + +const float ang = 2.0 * 3.14159 / float(RotNum); +mat2 m = mat2(cos(ang), sin(ang), -sin(ang), cos(ang)); + +float getRot(vec2 uv, float sc) { + float ang2 = angRnd * randS(uv).x * ang; + vec2 p = vec2(cos(ang2), sin(ang2)); + float rot = 0.0; + for (int i = 0; i < RotNum; i++) { + vec2 p2 = p * sc; + vec2 v = texture(iChannel0, fract(uv + p2)).xy - vec2(0.5); + rot += cross(vec3(v, 0.0), vec3(p2, 0.0)).z / dot(p2, p2); + p = m * p; + } + return rot / float(RotNum); +} + +// Multi-scale advection superposition +vec2 v = vec2(0); +float sc = 1.0 / max(iResolution.x, iResolution.y); +for (int level = 0; level < 20; level++) { + if (sc > 0.7) break; + vec2 p = vec2(cos(ang2), sin(ang2)); + for (int i = 0; i < RotNum; i++) { + vec2 p2 = p * sc; + float rot = getRot(uv + p2, sc); + v += p2.yx * rot * vec2(-1, 1); + p = m * p; + } + sc *= 2.0; +} +fragColor = texture(iChannel0, fract(uv + v * 3.0 / iResolution.x)); +``` + +### Variant 2: Vorticity Confinement +Adds vorticity confinement force on top of the basic solver, preventing small vortices from dissipating too quickly. +```glsl +#define VORT_STRENGTH 0.01 // [0.001 - 0.1] + +float curl_c = curl_at(uv); +float curl_n = abs(curl_at(uv + vec2(0, texel.y))); +float curl_s = abs(curl_at(uv - vec2(0, texel.y))); +float curl_e = abs(curl_at(uv + vec2(texel.x, 0))); +float curl_w = abs(curl_at(uv - vec2(texel.x, 0))); + +vec2 eta = normalize(vec2(curl_e - curl_w, curl_n - curl_s) + 1e-5); +vec2 conf = VORT_STRENGTH * vec2(eta.y, -eta.x) * curl_c; +c.xy += DT * conf; +``` + +### Variant 3: Viscous Fingering +Rotation-driven self-amplification + Laplacian diffusion, producing reaction-diffusion style organic patterns. +```glsl +const float cs = 0.25; // curl→rotation scale +const float ls = 0.24; // Laplacian diffusion strength +const float ps = -0.06; // divergence-pressure feedback +const float amp = 1.0; // self-amplification coefficient +const float pwr = 0.2; // curl power exponent + +float sc = cs * sign(curl) * pow(abs(curl), pwr); +float ta = amp * uv.x + ls * lapl.x + norm.x * sp + uv.x * sd; +float tb = amp * uv.y + ls * lapl.y + norm.y * sp + uv.y * sd; +float a = ta * cos(sc) - tb * sin(sc); +float b = ta * sin(sc) + tb * cos(sc); +fragColor = clamp(vec4(a, b, div, 1), -1.0, 1.0); +``` + +### Variant 4: Gaussian Kernel SPH Particle Fluid (Gaussian SPH) +Gaussian kernel function for density and velocity estimation, a grid-based approximation of SPH. +```glsl +#define RADIUS 7 // search radius [3-10] + +vec4 r = vec4(0); +for (vec2 i = vec2(-RADIUS); ++i.x < float(RADIUS);) + for (i.y = -float(RADIUS); ++i.y < float(RADIUS);) { + vec2 v = texelFetch(iChannel0, ivec2(i + fragCoord), 0).xy; + float mass = texelFetch(iChannel0, ivec2(i + fragCoord), 0).z; + float w = exp(-dot(v + i, v + i)) / 3.14; + r += mass * w * vec4(mix(v + v + i, v, mass), 1, 1); + } +r.xy /= r.z + 1e-6; +``` + +### Variant 5: Lagrangian Vortex Particle Method +Tracks discrete vortex particles, computing the velocity field using the Biot-Savart law. +```glsl +#define N 20 // N×N particles +#define STRENGTH 1e3*0.25 // vorticity strength scale + +vec2 F = vec2(0); +for (int j = 0; j < N; j++) + for (int i = 0; i < N; i++) { + float w = vorticity(i, j); + vec2 d = particle_pos(i, j) - my_pos; + float l = dot(d, d); + if (l > 1e-5) + F += vec2(-d.y, d.x) * w / l; + } +velocity = STRENGTH * F; +position += velocity * dt; +``` + +## Performance & Composition + +**Performance tips**: +- 5-point cross stencil is fastest; 3x3 (9 samples) is the best accuracy/performance tradeoff +- SPH search radius >7 is extremely slow; use `texelFetch` instead of `texture` to skip filtering +- Merge multiple steps into a single Pass; inter-frame feedback forms implicit Jacobi iteration +- Multi-step advection (`ADVECTION_STEPS=3`) improves accuracy but 3x sampling cost +- `textureLod` provides O(1) multi-scale reads replacing large-radius sampling +- Add slight noise (`1e-6`) on initial frames to break symmetry lock +- `fract(uv + offset)` implements periodic boundaries without branching +- Multiply pressure field by `0.9999` decay to prevent drift + +**Composition directions**: +- **+ Normal map lighting**: density field → height map → normals → Phong/GGX, liquid metal effects +- **+ Particle tracing**: passive particles update position following the flow field, visualizing streamlines/ink wash +- **+ Color advection**: extra channels store RGB, synchronous semi-Lagrangian advection, colorful blending +- **+ Audio response**: low freq → thrust, high freq → vortex perturbation, music-driven fluid +- **+ 3D volume rendering**: 2D slices packed as 3D voxels, ray marching to render clouds/explosions + +## Further Reading + +Full step-by-step tutorial, mathematical derivations, and advanced usage in [reference](../reference/fluid-simulation.md) diff --git a/skills/shader-dev/techniques/fractal-rendering.md b/skills/shader-dev/techniques/fractal-rendering.md new file mode 100644 index 0000000..ea46cb7 --- /dev/null +++ b/skills/shader-dev/techniques/fractal-rendering.md @@ -0,0 +1,436 @@ +# Fractal Rendering Skill + +## Use Cases +- Rendering self-similar mathematical structures: Mandelbrot/Julia sets (2D), Mandelbulb (3D), IFS fractals (Menger/Apollonian) +- Procedural textures or backgrounds requiring infinite detail +- Real-time generation of complex geometric visual effects (music visualization, sci-fi scenes, abstract art) +- Suitable for ShaderToy, demo scene, procedural content generation + +## Core Principles + +Fractal rendering is essentially **visualization of iterative systems**, falling into three categories: + +### 1. Escape-Time Algorithm +Iterate `Z <- Z^2 + c`, count escape steps. Distance estimation by simultaneously tracking the derivative `Z'`: +``` +Z <- Z^2 + c +Z' <- 2*Z*Z' + 1 +d(c) = |Z|*log|Z| / |Z'| +``` + +### 2. Iterated Function System (IFS / KIFS) +Fold-sort-scale-offset iteration produces self-similar structures: +``` +p = abs(p) // fold +sort p.xyz descending // sort +p = Scale * p - Offset * (Scale-1) // scale and offset +``` + +### 3. Spherical Inversion Fractals +`fract()` space folding + spherical inversion `p *= s/dot(p,p)`: +``` +p = -1.0 + 2.0 * fract(0.5*p + 0.5) +k = s / dot(p, p) +p *= k; scale *= k +``` + +All 3D fractals are rendered via **Sphere Tracing (Ray Marching)**. + +## Implementation Steps + +### Step 1: Coordinate Normalization +```glsl +vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; +``` + +### Step 2: 2D Mandelbrot Escape-Time Iteration +```glsl +float distanceToMandelbrot(in vec2 c) { + vec2 z = vec2(0.0); + vec2 dz = vec2(0.0); + float m2 = 0.0; + + for (int i = 0; i < MAX_ITER; i++) { + if (m2 > BAILOUT * BAILOUT) break; + // Z' -> 2*Z*Z' + 1 + dz = 2.0 * vec2(z.x*dz.x - z.y*dz.y, + z.x*dz.y + z.y*dz.x) + vec2(1.0, 0.0); + // Z -> Z^2 + c + z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c; + m2 = dot(z, z); + } + return 0.5 * sqrt(dot(z,z) / dot(dz,dz)) * log(dot(z,z)); +} +``` + +### Step 3: Mandelbulb Distance Field (Spherical Coordinate Power-N) +```glsl +float mandelbulb(vec3 p) { + vec3 z = p; + float dr = 1.0; + float r; + + for (int i = 0; i < FRACTAL_ITER; i++) { + r = length(z); + if (r > BAILOUT) break; + float theta = atan(z.y, z.x); + float phi = asin(z.z / r); + dr = pow(r, POWER - 1.0) * dr * POWER + 1.0; + r = pow(r, POWER); + theta *= POWER; + phi *= POWER; + z = r * vec3(cos(theta)*cos(phi), + sin(theta)*cos(phi), + sin(phi)) + p; + } + return 0.5 * log(r) * r / dr; +} +``` + +### Step 4: Menger Sponge Distance Field (KIFS) +```glsl +float mengerDE(vec3 z) { + z = abs(1.0 - mod(z, 2.0)); // infinite tiling + float d = 1000.0; + + for (int n = 0; n < IFS_ITER; n++) { + z = abs(z); + if (z.x < z.y) z.xy = z.yx; + if (z.x < z.z) z.xz = z.zx; + if (z.y < z.z) z.yz = z.zy; + z = SCALE * z - OFFSET * (SCALE - 1.0); + if (z.z < -0.5 * OFFSET.z * (SCALE - 1.0)) + z.z += OFFSET.z * (SCALE - 1.0); + d = min(d, length(z) * pow(SCALE, float(-n) - 1.0)); + } + return d - 0.001; +} +``` + +### Step 5: Apollonian Distance Field (Spherical Inversion) +```glsl +vec4 orb; // orbit trap + +float apollonianDE(vec3 p, float s) { + float scale = 1.0; + orb = vec4(1000.0); + + for (int i = 0; i < INVERSION_ITER; i++) { + p = -1.0 + 2.0 * fract(0.5 * p + 0.5); + float r2 = dot(p, p); + orb = min(orb, vec4(abs(p), r2)); + float k = s / r2; + p *= k; + scale *= k; + } + return 0.25 * abs(p.y) / scale; +} +``` + +### Step 6: Ray Marching +```glsl +float rayMarch(vec3 ro, vec3 rd) { + float t = 0.01; + for (int i = 0; i < MAX_STEPS; i++) { + float precis = PRECISION * t; + float h = map(ro + rd * t); + if (h < precis || t > MAX_DIST) break; + t += h * FUDGE_FACTOR; + } + return (t > MAX_DIST) ? -1.0 : t; +} +``` + +### Step 7: Normal Calculation +```glsl +// 4-tap tetrahedral method (recommended) +vec3 calcNormal(vec3 pos, float t) { + float precis = 0.001 * t; + vec2 e = vec2(1.0, -1.0) * precis; + return normalize( + e.xyy * map(pos + e.xyy) + + e.yyx * map(pos + e.yyx) + + e.yxy * map(pos + e.yxy) + + e.xxx * map(pos + e.xxx)); +} +``` + +### Step 8: Shading & Lighting +```glsl +vec3 shade(vec3 pos, vec3 nor, vec3 rd, vec4 trap) { + vec3 light1 = normalize(LIGHT_DIR); + float diff = clamp(dot(light1, nor), 0.0, 1.0); + float amb = 0.7 + 0.3 * nor.y; + float ao = pow(clamp(trap.w * 2.0, 0.0, 1.0), 1.2); + + vec3 brdf = vec3(0.4) * amb * ao + vec3(1.0) * diff * ao; + + vec3 rgb = vec3(1.0); + rgb = mix(rgb, vec3(1.0, 0.8, 0.2), clamp(6.0*trap.y, 0.0, 1.0)); + rgb = mix(rgb, vec3(1.0, 0.55, 0.0), pow(clamp(1.0-2.0*trap.z, 0.0, 1.0), 8.0)); + return rgb * brdf; +} +``` + +### Step 9: Camera +```glsl +void setupCamera(vec2 uv, vec3 ro, vec3 ta, float cr, out vec3 rd) { + vec3 cw = normalize(ta - ro); + vec3 cp = vec3(sin(cr), cos(cr), 0.0); + vec3 cu = normalize(cross(cw, cp)); + vec3 cv = normalize(cross(cu, cw)); + rd = normalize(uv.x * cu + uv.y * cv + 2.0 * cw); +} +``` + +## Complete Code Template + +3D Apollonian fractal (spherical inversion type) with full ray marching pipeline, orbit trap coloring, and AO. Ready to run in ShaderToy. + +```glsl +// Fractal Rendering — Apollonian (Spherical Inversion) Template + +#define MAX_STEPS 200 +#define MAX_DIST 30.0 +#define PRECISION 0.001 +#define INVERSION_ITER 8 // Tunable: 5-12 +#define AA 1 // Tunable: 1=no AA, 2=4xSSAA + +vec4 orb; + +float map(vec3 p, float s) { + float scale = 1.0; + orb = vec4(1000.0); + + for (int i = 0; i < INVERSION_ITER; i++) { + p = -1.0 + 2.0 * fract(0.5 * p + 0.5); + float r2 = dot(p, p); + orb = min(orb, vec4(abs(p), r2)); + float k = s / r2; + p *= k; + scale *= k; + } + return 0.25 * abs(p.y) / scale; +} + +float trace(vec3 ro, vec3 rd, float s) { + float t = 0.01; + for (int i = 0; i < MAX_STEPS; i++) { + float precis = PRECISION * t; + float h = map(ro + rd * t, s); + if (h < precis || t > MAX_DIST) break; + t += h; + } + return (t > MAX_DIST) ? -1.0 : t; +} + +vec3 calcNormal(vec3 pos, float t, float s) { + float precis = PRECISION * t; + vec2 e = vec2(1.0, -1.0) * precis; + return normalize( + e.xyy * map(pos + e.xyy, s) + + e.yyx * map(pos + e.yyx, s) + + e.yxy * map(pos + e.yxy, s) + + e.xxx * map(pos + e.xxx, s)); +} + +vec3 render(vec3 ro, vec3 rd, float anim) { + vec3 col = vec3(0.0); + float t = trace(ro, rd, anim); + + if (t > 0.0) { + vec4 tra = orb; + vec3 pos = ro + t * rd; + vec3 nor = calcNormal(pos, t, anim); + + vec3 light1 = normalize(vec3(0.577, 0.577, -0.577)); + vec3 light2 = normalize(vec3(-0.707, 0.0, 0.707)); + float key = clamp(dot(light1, nor), 0.0, 1.0); + float bac = clamp(0.2 + 0.8 * dot(light2, nor), 0.0, 1.0); + float amb = 0.7 + 0.3 * nor.y; + float ao = pow(clamp(tra.w * 2.0, 0.0, 1.0), 1.2); + + vec3 brdf = vec3(0.40) * amb * ao + + vec3(1.00) * key * ao + + vec3(0.40) * bac * ao; + + vec3 rgb = vec3(1.0); + rgb = mix(rgb, vec3(1.0, 0.80, 0.2), clamp(6.0 * tra.y, 0.0, 1.0)); + rgb = mix(rgb, vec3(1.0, 0.55, 0.0), pow(clamp(1.0 - 2.0*tra.z, 0.0, 1.0), 8.0)); + + col = rgb * brdf * exp(-0.2 * t); + } + return sqrt(col); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + float time = iTime * 0.25; + float anim = 1.1 + 0.5 * smoothstep(-0.3, 0.3, cos(0.1 * iTime)); + + vec3 tot = vec3(0.0); + + #if AA > 1 + for (int jj = 0; jj < AA; jj++) + for (int ii = 0; ii < AA; ii++) + #else + int ii = 1, jj = 1; + #endif + { + vec2 q = fragCoord.xy + vec2(float(ii), float(jj)) / float(AA); + vec2 p = (2.0 * q - iResolution.xy) / iResolution.y; + + vec3 ro = vec3(2.8*cos(0.1 + 0.33*time), + 0.4 + 0.3*cos(0.37*time), + 2.8*cos(0.5 + 0.35*time)); + vec3 ta = vec3(1.9*cos(1.2 + 0.41*time), + 0.4 + 0.1*cos(0.27*time), + 1.9*cos(2.0 + 0.38*time)); + float roll = 0.2 * cos(0.1 * time); + + vec3 cw = normalize(ta - ro); + vec3 cp = vec3(sin(roll), cos(roll), 0.0); + vec3 cu = normalize(cross(cw, cp)); + vec3 cv = normalize(cross(cu, cw)); + vec3 rd = normalize(p.x*cu + p.y*cv + 2.0*cw); + + tot += render(ro, rd, anim); + } + + tot /= float(AA * AA); + fragColor = vec4(tot, 1.0); +} +``` + +## Common Variants + +### 1. 2D Mandelbrot (Distance Estimation Coloring) +Pure 2D, no ray marching needed. Complex iteration + distance coloring. +```glsl +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 p = (2.0*fragCoord - iResolution.xy) / iResolution.y; + float tz = 0.5 - 0.5*cos(0.225*iTime); + float zoo = pow(0.5, 13.0*tz); + vec2 c = vec2(-0.05, 0.6805) + p * zoo; // Tunable: zoom center point + + vec2 z = vec2(0.0), dz = vec2(0.0); + for (int i = 0; i < 300; i++) { + if (dot(z,z) > 1024.0) break; + dz = 2.0*vec2(z.x*dz.x-z.y*dz.y, z.x*dz.y+z.y*dz.x) + vec2(1.0,0.0); + z = vec2(z.x*z.x-z.y*z.y, 2.0*z.x*z.y) + c; + } + + float d = 0.5*sqrt(dot(z,z)/dot(dz,dz))*log(dot(z,z)); + d = clamp(pow(4.0*d/zoo, 0.2), 0.0, 1.0); + fragColor = vec4(vec3(d), 1.0); +} +``` + +### 2. Mandelbulb Power-N +Spherical coordinate trigonometric functions; `POWER` parameter controls morphology. +```glsl +#define POWER 8.0 // Tunable: 2-16 +#define FRACTAL_ITER 4 // Tunable: 2-8 + +float mandelbulbDE(vec3 p) { + vec3 z = p; + float dr = 1.0, r; + for (int i = 0; i < FRACTAL_ITER; i++) { + r = length(z); + if (r > 2.0) break; + float theta = atan(z.y, z.x); + float phi = asin(z.z / r); + dr = pow(r, POWER - 1.0) * dr * POWER + 1.0; + r = pow(r, POWER); + theta *= POWER; phi *= POWER; + z = r * vec3(cos(theta)*cos(phi), sin(theta)*cos(phi), sin(phi)) + p; + } + return 0.5 * log(r) * r / dr; +} +``` + +### 3. Menger Sponge (KIFS) +`abs()` folding + conditional sorting, regular geometric fractal. +```glsl +#define SCALE 3.0 +#define OFFSET vec3(0.92858,0.92858,0.32858) +#define IFS_ITER 7 + +float mengerDE(vec3 z) { + z = abs(1.0 - mod(z, 2.0)); + float d = 1000.0; + for (int n = 0; n < IFS_ITER; n++) { + z = abs(z); + if (z.x < z.y) z.xy = z.yx; + if (z.x < z.z) z.xz = z.zx; + if (z.y < z.z) z.yz = z.zy; + z = SCALE * z - OFFSET * (SCALE - 1.0); + if (z.z < -0.5*OFFSET.z*(SCALE-1.0)) + z.z += OFFSET.z*(SCALE-1.0); + d = min(d, length(z) * pow(SCALE, float(-n)-1.0)); + } + return d - 0.001; +} +``` + +### 4. Quaternion Julia Set +Quaternion `Z <- Z^2 + c` (4D), with fixed `c` parameter; visualized by taking a 3D slice. +```glsl +vec4 qsqr(vec4 a) { + return vec4(a.x*a.x - a.y*a.y - a.z*a.z - a.w*a.w, + 2.0*a.x*a.y, 2.0*a.x*a.z, 2.0*a.x*a.w); +} + +float juliaDE(vec3 p, vec4 c) { + vec4 z = vec4(p, 0.0); + float md2 = 1.0, mz2 = dot(z, z); + for (int i = 0; i < 11; i++) { + md2 *= 4.0 * mz2; + z = qsqr(z) + c; + mz2 = dot(z, z); + if (mz2 > 4.0) break; + } + return 0.25 * sqrt(mz2 / md2) * log(mz2); +} +// Animated c: vec4 c = 0.45*cos(vec4(0.5,3.9,1.4,1.1)+time*vec4(1.2,1.7,1.3,2.5))-vec4(0.3,0,0,0); +``` + +### 5. Minimal IFS Field (2D, No Ray Marching) +`abs(p)/dot(p,p) + offset` iteration, weighted accumulation produces a density field. +```glsl +float field(vec3 p) { + float strength = 7.0 + 0.03 * log(1.e-6 + fract(sin(iTime) * 4373.11)); + float accum = 0.0, prev = 0.0, tw = 0.0; + for (int i = 0; i < 32; ++i) { + float mag = dot(p, p); + p = abs(p) / mag + vec3(-0.5, -0.4, -1.5); // Tunable: offset values + float w = exp(-float(i) / 7.0); + accum += w * exp(-strength * pow(abs(mag - prev), 2.3)); + tw += w; + prev = mag; + } + return max(0.0, 5.0 * accum / tw - 0.7); +} +``` + +## Performance & Composition + +### Performance Tips +- Core bottleneck: outer ray marching x inner fractal iteration (e.g., `200 x 8 = 1600` map calls per pixel) +- Reduce `MAX_STEPS` to 60-100, compensate with fudge factor 0.7-0.9 +- Hit threshold `precis = 0.001 * t` relaxes with distance +- Fractal iteration: break immediately when `|z|^2 > bailout` +- Reducing iterations from 8 to 4-5 has minimal visual impact +- Use 4-tap normals instead of 6-tap to save 33% +- Use AA=1 during development, AA=2 for release (AA=3 = 9x overhead) +- Avoid `pow()` inside loops; manually expand for low powers + +### Composition Techniques +- **Volumetric light**: accumulate `exp(-10.0 * h)` during ray march for god rays +- **Tone Mapping**: ACES + sRGB gamma for handling high-frequency detail +- **Transparent refraction**: negative distance field reverse ray march + Beer's law absorption +- **Orbit Trap coloring**: map trap values to HSV or emissive colors +- **Soft shadows**: ray march toward light, accumulate `min(k * h / t)` for soft shadows + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/fractal-rendering.md) diff --git a/skills/shader-dev/techniques/lighting-model.md b/skills/shader-dev/techniques/lighting-model.md new file mode 100644 index 0000000..a6ddc06 --- /dev/null +++ b/skills/shader-dev/techniques/lighting-model.md @@ -0,0 +1,527 @@ +# Lighting Models Skill + +## Use Cases +- Adding realistic lighting to raymarched or rasterized scenes +- Simulating light interaction with various materials (metal, dielectric, water, skin, etc.) +- From simple diffuse/specular to full PBR +- Multi-light compositing (sun, sky, ambient) +- Adding material appearance to SDF scenes in ShaderToy + +## Core Principles + +Lighting = Diffuse + Specular Reflection: + +- **Diffuse**: Lambert's law `I = max(0, N·L)` +- **Specular**: Empirical model uses Blinn-Phong `pow(max(0, N·H), shininess)`; physically-based model uses Cook-Torrance BRDF + +### Key Formulas + +``` +Lambert: L_diffuse = albedo * lightColor * max(0, N·L) +Blinn-Phong: H = normalize(V + L); L_specular = lightColor * pow(max(0, N·H), shininess) +Cook-Torrance: f_specular = D(h) * F(v,h) * G(l,v,h) / (4 * (N·L) * (N·V)) +Fresnel: F = F0 + (1 - F0) * (1 - V·H)^5 +``` + +- **D** = GGX/Trowbridge-Reitz normal distribution +- **F** = Schlick Fresnel approximation +- **G** = Smith geometric shadowing +- F0: dielectric ~0.04, metals use baseColor + +## Implementation Steps + +### Step 1: Scene Basics (Normal + Vector Setup) + +```glsl +// SDF normal (finite difference method) +vec3 calcNormal(vec3 p) { + vec2 e = vec2(0.001, 0.0); + return normalize(vec3( + map(p + e.xyy) - map(p - e.xyy), + map(p + e.yxy) - map(p - e.yxy), + map(p + e.yyx) - map(p - e.yyx) + )); +} + +vec3 N = calcNormal(pos); // surface normal +vec3 V = -rd; // view direction +vec3 L = normalize(lightPos - pos); // light direction (point light) +// directional light: vec3 L = normalize(vec3(0.6, 0.8, -0.5)); +``` + +### Step 2: Lambert Diffuse + +```glsl +float NdotL = max(0.0, dot(N, L)); +vec3 diffuse = albedo * lightColor * NdotL; + +// energy-conserving version +vec3 diffuse_conserved = albedo / PI * lightColor * NdotL; + +// Half-Lambert (reduces over-darkening on backlit faces, commonly used for SSS approximation) +float halfLambert = NdotL * 0.5 + 0.5; +vec3 diffuse_wrapped = albedo * lightColor * halfLambert; +``` + +### Step 3: Blinn-Phong Specular + +```glsl +vec3 H = normalize(V + L); +float NdotH = max(0.0, dot(N, H)); +float SHININESS = 32.0; // 4.0 (rough) ~ 256.0 (smooth) + +// with normalization factor for energy conservation +float normFactor = (SHININESS + 8.0) / (8.0 * PI); +float spec = normFactor * pow(NdotH, SHININESS); +vec3 specular = lightColor * spec; +``` + +### Step 4: Fresnel-Schlick + +```glsl +vec3 fresnelSchlick(vec3 F0, float cosTheta) { + return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); +} + +// metallic workflow +vec3 F0 = mix(vec3(0.04), baseColor, metallic); + +// computed with V·H (specular reflection BRDF) +float VdotH = max(0.0, dot(V, H)); +vec3 F = fresnelSchlick(F0, VdotH); + +// computed with N·V (environment reflection, rim light) +float NdotV = max(0.0, dot(N, V)); +vec3 F_env = fresnelSchlick(F0, NdotV); +``` + +### Step 5: GGX Normal Distribution (D Term) + +```glsl +float distributionGGX(float NdotH, float roughness) { + float a = roughness * roughness; // roughness must be squared first + float a2 = a * a; + float denom = NdotH * NdotH * (a2 - 1.0) + 1.0; + return a2 / (PI * denom * denom); +} +``` + +### Step 6: Geometric Shadowing (G Term) + +```glsl +// Method 1: Schlick-GGX +float geometrySchlickGGX(float NdotV, float roughness) { + float r = roughness + 1.0; + float k = (r * r) / 8.0; + return NdotV / (NdotV * (1.0 - k) + k); +} +float geometrySmith(float NdotV, float NdotL, float roughness) { + return geometrySchlickGGX(NdotV, roughness) * geometrySchlickGGX(NdotL, roughness); +} + +// Method 2: Height-Correlated Smith (more accurate, directly returns the visibility term) +float visibilitySmith(float NdotV, float NdotL, float roughness) { + float a2 = roughness * roughness; + float gv = NdotL * sqrt(NdotV * (NdotV - NdotV * a2) + a2); + float gl = NdotV * sqrt(NdotL * (NdotL - NdotL * a2) + a2); + return 0.5 / max(gv + gl, 0.00001); +} + +// Method 3: Simplified approximation +float G1V(float dotNV, float k) { + return 1.0 / (dotNV * (1.0 - k) + k); +} +// Usage: float vis = G1V(NdotL, k) * G1V(NdotV, k); where k = roughness/2 +``` + +### Step 7: Assembling Cook-Torrance BRDF + +```glsl +vec3 cookTorranceBRDF(vec3 N, vec3 V, vec3 L, float roughness, vec3 F0) { + vec3 H = normalize(V + L); + float NdotL = max(0.0, dot(N, L)); + float NdotV = max(0.0, dot(N, V)); + float NdotH = max(0.0, dot(N, H)); + float VdotH = max(0.0, dot(V, H)); + + float D = distributionGGX(NdotH, roughness); + vec3 F = fresnelSchlick(F0, VdotH); + float Vis = visibilitySmith(NdotV, NdotL, roughness); + + // Vis version already includes the 4*NdotV*NdotL denominator + vec3 specular = D * F * Vis; + // Or with standard G term: specular = (D * F * G) / max(4.0 * NdotV * NdotL, 0.001); + + return specular * NdotL; +} +``` + +### Step 8: Multi-Light Accumulation and Compositing + +```glsl +vec3 shade(vec3 pos, vec3 N, vec3 V, vec3 albedo, float roughness, float metallic) { + vec3 F0 = mix(vec3(0.04), albedo, metallic); + vec3 diffuseColor = albedo * (1.0 - metallic); // metals have no diffuse + vec3 color = vec3(0.0); + + // primary light (sun) + vec3 sunDir = normalize(vec3(0.6, 0.8, -0.5)); + vec3 sunColor = vec3(1.0, 0.95, 0.85) * 2.0; + vec3 H = normalize(V + sunDir); + float NdotL = max(0.0, dot(N, sunDir)); + float NdotV = max(0.0, dot(N, V)); + float VdotH = max(0.0, dot(V, H)); + vec3 F = fresnelSchlick(F0, VdotH); + vec3 kD = (1.0 - F) * (1.0 - metallic); // energy conservation + + color += kD * diffuseColor / PI * sunColor * NdotL; + color += cookTorranceBRDF(N, V, sunDir, roughness, F0) * sunColor; + + // sky light (hemisphere approximation) + vec3 skyColor = vec3(0.2, 0.5, 1.0) * 0.3; + float skyDiffuse = 0.5 + 0.5 * N.y; + color += diffuseColor * skyColor * skyDiffuse; + + // back light / rim light + vec3 backDir = normalize(vec3(-sunDir.x, 0.0, -sunDir.z)); + float backDiffuse = clamp(dot(N, backDir) * 0.5 + 0.5, 0.0, 1.0); + color += diffuseColor * vec3(0.25, 0.15, 0.1) * backDiffuse; + + return color; +} +``` + +### Step 9: Ambient Occlusion (AO) + +```glsl +// Raymarching AO (using SDF queries) +float calcAO(vec3 pos, vec3 nor) { + float occ = 0.0; + float sca = 1.0; + for (int i = 0; i < 5; i++) { + float h = 0.01 + 0.12 * float(i) / 4.0; + float d = map(pos + h * nor); + occ += (h - d) * sca; + sca *= 0.95; + } + return clamp(1.0 - 3.0 * occ, 0.0, 1.0); +} + +float ao = calcAO(pos, N); +diffuseLight *= ao; +// specular AO (more subtle): +specularLight *= clamp(pow(NdotV + ao, roughness * roughness) - 1.0 + ao, 0.0, 1.0); +``` + +### Outdoor Three-Light Model + +The go-to lighting setup for outdoor SDF scenes. Uses three directional sources to approximate full global illumination with minimal cost: + +```glsl +// === Outdoor Three-Light Lighting === +// Compute material, occlusion, and shadow first +vec3 material = getMaterial(pos, nor); // albedo, keep ≤ 0.2 for realism +float occ = calcAO(pos, nor); // ambient occlusion +float sha = calcSoftShadow(pos, sunDir, 0.02, 8.0); + +// Three light contributions +float sun = clamp(dot(nor, sunDir), 0.0, 1.0); // direct sunlight +float sky = clamp(0.5 + 0.5 * nor.y, 0.0, 1.0); // hemisphere sky light +float ind = clamp(dot(nor, normalize(sunDir * vec3(-1.0, 0.0, -1.0))), 0.0, 1.0); // indirect bounce + +// Combine with colored shadows (key technique: shadow penumbra tints blue) +vec3 lin = vec3(0.0); +lin += sun * vec3(1.64, 1.27, 0.99) * pow(vec3(sha), vec3(1.0, 1.2, 1.5)); // warm sun, colored shadow +lin += sky * vec3(0.16, 0.20, 0.28) * occ; // cool sky fill +lin += ind * vec3(0.40, 0.28, 0.20) * occ; // warm ground bounce + +vec3 color = material * lin; +``` + +Key principles: +- **Colored shadow penumbra**: `pow(vec3(sha), vec3(1.0, 1.2, 1.5))` makes shadow edges slightly blue/cool, mimicking real subsurface scattering in penumbra regions +- **Material albedo rule**: Keep diffuse albedo ≤ 0.2; adjust light intensities for brightness, not material values. Real-world surfaces rarely exceed 0.3 albedo +- **Linear workflow**: All computations in linear space, apply gamma `pow(color, vec3(1.0/2.2))` at the very end +- **Sky light approximation**: `0.5 + 0.5 * nor.y` is a cheap hemisphere integral — surfaces pointing up get full sky, pointing down get none +- Do NOT apply ambient occlusion to the sun/key light — shadows handle that + +## Complete Code Template + +```glsl +// Lighting Model Complete Template - Runs directly in ShaderToy +// Progressive implementation from Lambert to Cook-Torrance PBR + +#define PI 3.14159265359 + +// ========== Adjustable Parameters ========== +#define ROUGHNESS 0.35 +#define METALLIC 0.0 +#define ALBEDO vec3(0.8, 0.2, 0.2) +#define SUN_DIR normalize(vec3(0.6, 0.8, -0.5)) +#define SUN_COLOR vec3(1.0, 0.95, 0.85) * 2.0 +#define SKY_COLOR vec3(0.2, 0.5, 1.0) * 0.4 +#define BACKGROUND_TOP vec3(0.5, 0.7, 1.0) +#define BACKGROUND_BOT vec3(0.8, 0.85, 0.9) + +// ========== SDF Scene ========== +float map(vec3 p) { + float sphere = length(p - vec3(0.0, 0.0, 0.0)) - 1.0; + float ground = p.y + 1.0; + return min(sphere, ground); +} + +vec3 calcNormal(vec3 p) { + vec2 e = vec2(0.001, 0.0); + return normalize(vec3( + map(p + e.xyy) - map(p - e.xyy), + map(p + e.yxy) - map(p - e.yxy), + map(p + e.yyx) - map(p - e.yyx) + )); +} + +// ========== AO ========== +float calcAO(vec3 pos, vec3 nor) { + float occ = 0.0; + float sca = 1.0; + for (int i = 0; i < 5; i++) { + float h = 0.01 + 0.12 * float(i) / 4.0; + float d = map(pos + h * nor); + occ += (h - d) * sca; + sca *= 0.95; + } + return clamp(1.0 - 3.0 * occ, 0.0, 1.0); +} + +// ========== Soft Shadow ========== +float softShadow(vec3 ro, vec3 rd, float mint, float maxt) { + float res = 1.0; + float t = mint; + for (int i = 0; i < 24; i++) { + float h = map(ro + rd * t); + res = min(res, 8.0 * h / t); + t += clamp(h, 0.02, 0.2); + if (res < 0.001 || t > maxt) break; + } + return clamp(res, 0.0, 1.0); +} + +// ========== PBR BRDF Components ========== +float D_GGX(float NdotH, float roughness) { + float a = roughness * roughness; + float a2 = a * a; + float d = NdotH * NdotH * (a2 - 1.0) + 1.0; + return a2 / (PI * d * d); +} + +vec3 F_Schlick(vec3 F0, float cosTheta) { + return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); +} + +float V_SmithGGX(float NdotV, float NdotL, float roughness) { + float a2 = roughness * roughness; + a2 *= a2; + float gv = NdotL * sqrt(NdotV * NdotV * (1.0 - a2) + a2); + float gl = NdotV * sqrt(NdotL * NdotL * (1.0 - a2) + a2); + return 0.5 / max(gv + gl, 1e-5); +} + +// ========== Complete Lighting ========== +vec3 shade(vec3 pos, vec3 N, vec3 V, vec3 albedo, float roughness, float metallic) { + vec3 F0 = mix(vec3(0.04), albedo, metallic); + vec3 diffuseColor = albedo * (1.0 - metallic); + float NdotV = max(dot(N, V), 1e-4); + float ao = calcAO(pos, N); + vec3 color = vec3(0.0); + + // sunlight + { + vec3 L = SUN_DIR; + vec3 H = normalize(V + L); + float NdotL = max(dot(N, L), 0.0); + float NdotH = max(dot(N, H), 0.0); + float VdotH = max(dot(V, H), 0.0); + float D = D_GGX(NdotH, roughness); + vec3 F = F_Schlick(F0, VdotH); + float Vis = V_SmithGGX(NdotV, NdotL, roughness); + vec3 kD = (1.0 - F) * (1.0 - metallic); + vec3 diffuse = kD * diffuseColor / PI; + vec3 specular = D * F * Vis; + float shadow = softShadow(pos, L, 0.02, 5.0); + color += (diffuse + specular) * SUN_COLOR * NdotL * shadow; + } + + // sky light (hemisphere approximation) + { + float skyDiff = 0.5 + 0.5 * N.y; + color += diffuseColor * SKY_COLOR * skyDiff * ao; + } + + // back light / rim light + { + vec3 backDir = normalize(vec3(-SUN_DIR.x, 0.0, -SUN_DIR.z)); + float backDiff = clamp(dot(N, backDir) * 0.5 + 0.5, 0.0, 1.0); + color += diffuseColor * vec3(0.15, 0.1, 0.08) * backDiff * ao; + } + + // environment reflection (simplified) + { + vec3 R = reflect(-V, N); + vec3 envColor = mix(BACKGROUND_BOT, BACKGROUND_TOP, clamp(R.y * 0.5 + 0.5, 0.0, 1.0)); + vec3 F_env = F_Schlick(F0, NdotV); + float envOcc = clamp(pow(NdotV + ao, roughness * roughness) - 1.0 + ao, 0.0, 1.0); + color += F_env * envColor * envOcc * (1.0 - roughness * 0.7); + } + + return color; +} + +// ========== Raymarching ========== +float raymarch(vec3 ro, vec3 rd) { + float t = 0.0; + for (int i = 0; i < 128; i++) { + float d = map(ro + rd * t); + if (d < 0.001) return t; + t += d; + if (t > 50.0) break; + } + return -1.0; +} + +// ========== Background ========== +vec3 background(vec3 rd) { + vec3 col = mix(BACKGROUND_BOT, BACKGROUND_TOP, clamp(rd.y * 0.5 + 0.5, 0.0, 1.0)); + float sun = clamp(dot(rd, SUN_DIR), 0.0, 1.0); + col += SUN_COLOR * 0.3 * pow(sun, 8.0); + col += SUN_COLOR * 1.0 * pow(sun, 256.0); + return col; +} + +// ========== Main Function ========== +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; + + float angle = iTime * 0.3; + vec3 ro = vec3(3.0 * cos(angle), 1.5, 3.0 * sin(angle)); + vec3 ta = vec3(0.0, 0.0, 0.0); + vec3 ww = normalize(ta - ro); + vec3 uu = normalize(cross(ww, vec3(0.0, 1.0, 0.0))); + vec3 vv = cross(uu, ww); + vec3 rd = normalize(uv.x * uu + uv.y * vv + 1.5 * ww); + + vec3 col = background(rd); + float t = raymarch(ro, rd); + + if (t > 0.0) { + vec3 pos = ro + t * rd; + vec3 N = calcNormal(pos); + vec3 V = -rd; + vec3 albedo = ALBEDO; + float roughness = ROUGHNESS; + float metallic = METALLIC; + + if (pos.y < -0.99) { + roughness = 0.8; + metallic = 0.0; + float checker = mod(floor(pos.x) + floor(pos.z), 2.0); + albedo = mix(vec3(0.3), vec3(0.6), checker); + } + + col = shade(pos, N, V, albedo, roughness, metallic); + } + + col = col / (col + vec3(1.0)); // Tone mapping (Reinhard) + col = pow(col, vec3(1.0 / 2.2)); // Gamma + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Variant 1: Classic Phong (Non-PBR) + +```glsl +vec3 R = reflect(-L, N); +float spec = pow(max(0.0, dot(R, V)), 32.0); +vec3 color = albedo * lightColor * NdotL + lightColor * spec; +``` + +### Variant 2: Point Light Attenuation + +```glsl +float dist = length(lightPos - pos); +float attenuation = 1.0 / (1.0 + dist * 0.1 + dist * dist * 0.01); +color *= attenuation; +``` + +### Variant 3: IBL (Image-Based Lighting) + +```glsl +// diffuse IBL: spherical harmonics +vec3 diffuseIBL = diffuseColor * SHIrradiance(N); + +// specular IBL: EnvBRDFApprox +vec3 EnvBRDFApprox(vec3 specColor, float roughness, float NdotV) { + vec4 c0 = vec4(-1, -0.0275, -0.572, 0.022); + vec4 c1 = vec4(1, 0.0425, 1.04, -0.04); + vec4 r = roughness * c0 + c1; + float a004 = min(r.x * r.x, exp2(-9.28 * NdotV)) * r.x + r.y; + vec2 AB = vec2(-1.04, 1.04) * a004 + r.zw; + return specColor * AB.x + AB.y; +} +vec3 R = reflect(-V, N); +vec3 envColor = textureLod(envMap, R, roughness * 7.0).rgb; +vec3 specularIBL = EnvBRDFApprox(F0, roughness, NdotV) * envColor; +``` + +### Variant 4: Subsurface Scattering Approximation (SSS) + +```glsl +// SDF-based interior probing +float subsurface(vec3 pos, vec3 L) { + float sss = 0.0; + for (int i = 0; i < 5; i++) { + float h = 0.05 + float(i) * 0.1; + float d = map(pos + L * h); + sss += max(0.0, h - d); + } + return clamp(1.0 - sss * 4.0, 0.0, 1.0); +} + +// Henyey-Greenstein phase function +float HenyeyGreenstein(float cosTheta, float g) { + float g2 = g * g; + return (1.0 - g2) / (pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5) * 4.0 * PI); +} +float sssAmount = HenyeyGreenstein(dot(V, L), 0.5); +color += sssColor * sssAmount * NdotL; +``` + +### Variant 5: Beer's Law Water Lighting + +```glsl +vec3 waterExtinction(float depth) { + float opticalDepth = depth * 6.0; + vec3 extinctColor = 1.0 - vec3(0.5, 0.4, 0.1); + return exp2(-opticalDepth * extinctColor); +} +vec3 underwaterColor = objectColor * waterExtinction(depth); +vec3 inscatter = waterDiffuse * (1.0 - exp(-depth * 0.1)); +underwaterColor += inscatter; +``` + +## Performance & Composition + +- **Fresnel optimization**: Use `x2*x2*x` instead of `pow(x, 5.0)` +- **Visibility term**: Use `V_SmithGGX` to directly return `G/(4*NdotV*NdotL)`, avoiding separate division +- **AO sampling**: 5 samples is sufficient; can reduce to 3 at far distances +- **Soft shadow**: `clamp(h, 0.02, 0.2)` limits step size; 14~24 steps usually sufficient; `8.0*h/t` controls softness +- **Simplified IBL**: Without cubemap, approximate with `mix(groundColor, skyColor, R.y*0.5+0.5)` +- **Branch culling**: Skip specular calculation when `NdotL <= 0` +- **Raymarching integration**: Use SDF finite differences for normals, query SDF directly for AO/shadows +- **Volume rendering integration**: Beer's Law attenuation + Henyey-Greenstein phase function; FBM noise procedural normals can be passed directly to lighting functions +- **Post-processing integration**: ACES `(col*(2.51*col+0.03))/(col*(2.43*col+0.59)+0.14)` / Reinhard `col/(col+1)` + Gamma +- **Reflection integration**: `reflect(rd, N)` to query scene again, blend result with Fresnel weighting + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/lighting-model.md) diff --git a/skills/shader-dev/techniques/matrix-transform.md b/skills/shader-dev/techniques/matrix-transform.md new file mode 100644 index 0000000..9804e64 --- /dev/null +++ b/skills/shader-dev/techniques/matrix-transform.md @@ -0,0 +1,455 @@ +# Matrix Transforms & Camera + +## Use Cases + +- Camera systems in 3D scenes (orbit camera, fly camera, path camera) +- SDF object domain transforms via translation, rotation, and scale matrices +- Generating 3D rays from screen pixels (perspective / orthographic projection) +- Hierarchical rotation transforms for joint animation +- Rotation in noise domain warping, IFS fractal iterations + +## Core Principles + +The essence of matrix transforms is coordinate system transformation. In a ray marching pipeline: + +1. **Camera matrix**: Screen pixels → world-space ray direction (view-to-world) +2. **Object transform matrix**: World-space sample point → object local space (world-to-local, domain transform) + +### Key Formulas + +**2D Rotation** R(θ) = `[[cosθ, -sinθ], [sinθ, cosθ]]` + +**3D Rotation Around Y-Axis** Ry(θ) = `[[cosθ, 0, sinθ], [0, 1, 0], [-sinθ, 0, cosθ]]` + +**Rodrigues (Arbitrary Axis k, Angle θ)**: `R = cosθ·I + (1-cosθ)·k⊗k + sinθ·K` + +**LookAt Camera**: +``` +forward = normalize(target - eye) +right = normalize(cross(forward, worldUp)) +up = cross(right, forward) +viewMatrix = mat3(right, up, forward) +``` + +**Perspective Ray**: `rd = normalize(camMatrix * vec3(uv, focalLength))` + +## Implementation Steps + +### Step 1: Screen Coordinate Normalization + +```glsl +// Range [-aspect, aspect] x [-1, 1] +vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; +``` + +### Step 2: Rotation Matrices + +```glsl +// 2D rotation (mat2) +mat2 rot2D(float a) { + float c = cos(a), s = sin(a); + return mat2(c, s, -s, c); +} + +// 3D single-axis rotation (mat3) +mat3 rotX(float a) { + float s = sin(a), c = cos(a); + return mat3(1, 0, 0, 0, c, s, 0, -s, c); +} +mat3 rotY(float a) { + float s = sin(a), c = cos(a); + return mat3(c, 0, s, 0, 1, 0, -s, 0, c); +} +mat3 rotZ(float a) { + float s = sin(a), c = cos(a); + return mat3(c, s, 0, -s, c, 0, 0, 0, 1); +} + +// Euler angles → mat3 (yaw/pitch/roll) +mat3 fromEuler(vec3 ang) { + vec2 a1 = vec2(sin(ang.x), cos(ang.x)); + vec2 a2 = vec2(sin(ang.y), cos(ang.y)); + vec2 a3 = vec2(sin(ang.z), cos(ang.z)); + mat3 m; + m[0] = vec3( a1.y*a3.y + a1.x*a2.x*a3.x, + a1.y*a2.x*a3.x + a3.y*a1.x, + -a2.y*a3.x); + m[1] = vec3(-a2.y*a1.x, a1.y*a2.y, a2.x); + m[2] = vec3( a3.y*a1.x*a2.x + a1.y*a3.x, + a1.x*a3.x - a1.y*a3.y*a2.x, + a2.y*a3.y); + return m; +} + +// Rodrigues arbitrary-axis rotation (mat3) +mat3 rotationMatrix(vec3 axis, float angle) { + axis = normalize(axis); + float s = sin(angle), c = cos(angle), oc = 1.0 - c; + return mat3( + oc*axis.x*axis.x + c, oc*axis.x*axis.y - axis.z*s, oc*axis.z*axis.x + axis.y*s, + oc*axis.x*axis.y + axis.z*s, oc*axis.y*axis.y + c, oc*axis.y*axis.z - axis.x*s, + oc*axis.z*axis.x - axis.y*s, oc*axis.y*axis.z + axis.x*s, oc*axis.z*axis.z + c + ); +} +``` + +### Step 3: LookAt Camera + +```glsl +// Classic setCamera, cr = camera roll +mat3 setCamera(in vec3 ro, in vec3 ta, float cr) { + vec3 cw = normalize(ta - ro); + vec3 cp = vec3(sin(cr), cos(cr), 0.0); + vec3 cu = normalize(cross(cw, cp)); + vec3 cv = normalize(cross(cu, cw)); + return mat3(cu, cv, cw); +} + +// mat4 LookAt (with translation, for homogeneous coordinate scenes) +mat4 LookAt(vec3 pos, vec3 target, vec3 up) { + vec3 dir = normalize(target - pos); + vec3 x = normalize(cross(dir, up)); + vec3 y = cross(x, dir); + return mat4(vec4(x, 0), vec4(y, 0), vec4(dir, 0), vec4(pos, 1)); +} +``` + +### Step 4: Perspective Ray Generation + +```glsl +// mat3 camera — focalLength controls FOV: 1.0≈90°, 2.0≈53°, 4.0≈28° +#define FOCAL_LENGTH 2.0 +mat3 cam = setCamera(ro, ta, 0.0); +vec3 rd = cam * normalize(vec3(uv, FOCAL_LENGTH)); + +// Manual basis vector composition +#define FOV 1.0 +vec3 rd = normalize(camDir + (uv.x * camRight + uv.y * camUp) * FOV); + +// mat4 homogeneous coordinates +mat4 viewToWorld = LookAt(camPos, camTarget, camUp); +vec3 rd = (viewToWorld * normalize(vec4(uv, 1.0, 0.0))).xyz; +``` + +### Step 5: Mouse-Interactive Camera + +```glsl +// Spherical coordinate orbit camera +#define CAM_DIST 5.0 +#define CAM_HEIGHT 1.0 + +vec2 mouse = iMouse.xy / iResolution.xy; +float angleH = mouse.x * 6.2832; +float angleV = mouse.y * 3.1416 - 1.5708; + +if (iMouse.z <= 0.0) { + angleH = iTime * 0.5; + angleV = 0.3; +} + +vec3 ro = vec3( + CAM_DIST * cos(angleH) * cos(angleV), + CAM_DIST * sin(angleV) + CAM_HEIGHT, + CAM_DIST * sin(angleH) * cos(angleV) +); +vec3 ta = vec3(0.0); +``` + +### Step 6: SDF Domain Transforms + +```glsl +// Translation +float d = sdSphere(p - vec3(2.0, 0.0, 0.0), 1.0); + +// Rotation (orthogonal matrix inverse = transpose) +float d = sdBox(rotY(0.5) * p, vec3(1.0)); + +// Scale (divide by scale factor, multiply back into distance) +#define SCALE 2.0 +float d = sdSphere(p / SCALE, 1.0) * SCALE; + +// mat4 SRT composition +mat4 Loc4(vec3 d) { + d *= -1.0; + return mat4(1,0,0,d.x, 0,1,0,d.y, 0,0,1,d.z, 0,0,0,1); +} + +mat4 transposeM4(in mat4 m) { + return mat4( + vec4(m[0].x, m[1].x, m[2].x, m[3].x), + vec4(m[0].y, m[1].y, m[2].y, m[3].y), + vec4(m[0].z, m[1].z, m[2].z, m[3].z), + vec4(m[0].w, m[1].w, m[2].w, m[3].w)); +} + +vec3 opTx(vec3 p, mat4 m) { + return (transposeM4(m) * vec4(p, 1.0)).xyz; +} + +// First translate to (3,0,0), then rotate 45° around Y-axis +mat4 xform = Rot4Y(0.785) * Loc4(vec3(3.0, 0.0, 0.0)); +float d = sdBox(opTx(p, xform), vec3(1.0)); +``` + +### Step 7: Quaternion Rotation + +```glsl +vec4 axisAngleToQuat(vec3 axis, float angleDeg) { + float half_angle = angleDeg * 3.14159265 / 360.0; + vec2 sc = sin(vec2(half_angle, half_angle + 1.5707963)); + return vec4(normalize(axis) * sc.x, sc.y); +} + +vec3 quatRotate(vec3 pos, vec3 axis, float angleDeg) { + vec4 q = axisAngleToQuat(axis, angleDeg); + return pos + 2.0 * cross(q.xyz, cross(q.xyz, pos) + q.w * pos); +} + +// Hierarchical rotation in joint animation +vec3 limbPos = quatRotate(p - shoulderOffset, vec3(1,0,0), swingAngle); +float d = sdEllipsoid(limbPos, limbSize); +``` + +## Complete Code Template + +Can be run directly in ShaderToy, demonstrating LookAt camera + multi-object domain transforms + mouse interaction. + +```glsl +// === Matrix Transforms & Camera - Complete Template === + +#define PI 3.14159265 +#define MAX_STEPS 128 +#define MAX_DIST 50.0 +#define SURF_DIST 0.001 +#define FOCAL_LENGTH 2.0 +#define CAM_DIST 6.0 +#define AUTO_SPEED 0.4 + +// ---------- Rotation Matrix Utilities ---------- + +mat2 rot2D(float a) { + float c = cos(a), s = sin(a); + return mat2(c, s, -s, c); +} + +mat3 rotX(float a) { + float s = sin(a), c = cos(a); + return mat3(1,0,0, 0,c,s, 0,-s,c); +} + +mat3 rotY(float a) { + float s = sin(a), c = cos(a); + return mat3(c,0,s, 0,1,0, -s,0,c); +} + +mat3 rotZ(float a) { + float s = sin(a), c = cos(a); + return mat3(c,s,0, -s,c,0, 0,0,1); +} + +mat3 rotAxis(vec3 axis, float angle) { + axis = normalize(axis); + float s = sin(angle), c = cos(angle), oc = 1.0 - c; + return mat3( + oc*axis.x*axis.x+c, oc*axis.x*axis.y-axis.z*s, oc*axis.z*axis.x+axis.y*s, + oc*axis.x*axis.y+axis.z*s, oc*axis.y*axis.y+c, oc*axis.y*axis.z-axis.x*s, + oc*axis.z*axis.x-axis.y*s, oc*axis.y*axis.z+axis.x*s, oc*axis.z*axis.z+c + ); +} + +// ---------- LookAt Camera ---------- + +mat3 setCamera(vec3 ro, vec3 ta, float cr) { + vec3 cw = normalize(ta - ro); + vec3 cp = vec3(sin(cr), cos(cr), 0.0); + vec3 cu = normalize(cross(cw, cp)); + vec3 cv = normalize(cross(cu, cw)); + return mat3(cu, cv, cw); +} + +// ---------- SDF Primitives ---------- + +float sdSphere(vec3 p, float r) { return length(p) - r; } + +float sdBox(vec3 p, vec3 b) { + vec3 q = abs(p) - b; + return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0); +} + +float sdTorus(vec3 p, vec2 t) { + vec2 q = vec2(length(p.xz) - t.x, p.y); + return length(q) - t.y; +} + +// ---------- Scene (Domain Transform Demo) ---------- + +float map(vec3 p) { + float d = p.y + 1.0; // Ground plane + + // Static sphere + d = min(d, sdSphere(p, 0.5)); + + // Rotating box (spinning around Y-axis) + vec3 p2 = p - vec3(2.5, 0.0, 0.0); + p2 = rotY(iTime * 0.8) * p2; + d = min(d, sdBox(p2, vec3(0.6))); + + // Arbitrary-axis rotating torus + vec3 p3 = p - vec3(-2.5, 0.5, 0.0); + p3 = rotAxis(vec3(1,1,0), iTime * 0.6) * p3; + d = min(d, sdTorus(p3, vec2(0.6, 0.2))); + + // Scaled + rotated sphere + vec3 p4 = p - vec3(0.0, 0.5, 2.5); + p4 = rotZ(iTime * 1.2) * rotX(iTime * 0.7) * p4; + float scale = 1.5; + d = min(d, sdSphere(p4 / scale, 0.4) * scale); + + return d; +} + +// ---------- Normal ---------- + +vec3 calcNormal(vec3 p) { + vec2 e = vec2(0.001, 0.0); + return normalize(vec3( + map(p + e.xyy) - map(p - e.xyy), + map(p + e.yxy) - map(p - e.yxy), + map(p + e.yyx) - map(p - e.yyx) + )); +} + +// ---------- Ray March ---------- + +float rayMarch(vec3 ro, vec3 rd) { + float t = 0.0; + for (int i = 0; i < MAX_STEPS; i++) { + vec3 p = ro + rd * t; + float d = map(p); + if (d < SURF_DIST) break; + t += d; + if (t > MAX_DIST) break; + } + return t; +} + +// ---------- Main Function ---------- + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; + + // Mouse-interactive orbit camera + float angleH, angleV; + if (iMouse.z > 0.0) { + vec2 m = iMouse.xy / iResolution.xy; + angleH = m.x * 2.0 * PI; + angleV = (m.y - 0.5) * PI; + } else { + angleH = iTime * AUTO_SPEED; + angleV = 0.35; + } + + vec3 ro = vec3( + CAM_DIST * cos(angleH) * cos(angleV), + CAM_DIST * sin(angleV) + 1.0, + CAM_DIST * sin(angleH) * cos(angleV) + ); + vec3 ta = vec3(0.0); + + mat3 cam = setCamera(ro, ta, 0.0); + vec3 rd = cam * normalize(vec3(uv, FOCAL_LENGTH)); + + float t = rayMarch(ro, rd); + + vec3 col = vec3(0.0); + if (t < MAX_DIST) { + vec3 p = ro + rd * t; + vec3 n = calcNormal(p); + vec3 lightDir = normalize(vec3(1.0, 2.0, -1.0)); + float diff = max(dot(n, lightDir), 0.0); + col = vec3(0.8, 0.85, 0.9) * (diff + 0.15); + if (p.y < -0.99) { + float checker = mod(floor(p.x) + floor(p.z), 2.0); + col *= 0.5 + 0.3 * checker; + } + } else { + col = vec3(0.4, 0.6, 0.9) - rd.y * 0.3; + } + + col = pow(col, vec3(0.4545)); + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Orthographic Projection Camera + +```glsl +#define ORTHO_SIZE 5.0 +mat3 cam = setCamera(ro, ta, 0.0); +vec3 rd = cam * vec3(0.0, 0.0, 1.0); // Fixed direction +ro += cam * vec3(uv * ORTHO_SIZE, 0.0); // Offset origin +``` + +### Euler Angle Full Rotation Camera + +```glsl +vec3 ang = vec3(pitch, yaw, roll); +mat3 rot = fromEuler(ang); +vec3 ori = vec3(0.0, 0.0, 3.0) * rot; +vec3 rd = normalize(vec3(uv, -2.0)) * rot; +``` + +### Quaternion Joint Rotation + +```glsl +vec3 legP = quatRotate(p - hipOffset, vec3(1,0,0), legAngle); +float dLeg = sdEllipsoid(legP, vec3(0.2, 0.6, 0.25)); +``` + +### mat4 SRT Pipeline + +```glsl +mat4 Rot4Y(float a) { + float c = cos(a), s = sin(a); + return mat4(c,0,s,0, 0,1,0,0, -s,0,c,0, 0,0,0,1); +} + +mat4 xform = Rot4Y(angle) * Loc4(vec3(3.0, 0.0, 0.0)); +float d = sdBox(opTx(p, xform), boxSize); +``` + +### Path Camera (Animated Flight) + +```glsl +vec2 pathCenter(float z) { + return vec2(sin(z * 0.17) * 3.0, sin(z * 0.1 + 4.0) * 2.0); +} + +float z_offset = iTime * 10.0; +vec3 camPos = vec3(pathCenter(z_offset), 0.0); +vec3 camTarget = vec3(pathCenter(z_offset + 5.0), 5.0); +mat4 viewToWorld = LookAt(camPos, camTarget, camUp); +vec3 rd = (viewToWorld * normalize(vec4(uv, 1.0, 0.0))).xyz; +``` + +## Performance & Composition + +**Performance**: +- Compute `sin/cos` of the same angle only once: `vec2 sc = sin(vec2(a, a + 1.5707963));` +- Use `mat3` instead of `mat4` for pure rotation (saves 7 multiply-adds) +- Inverse of orthogonal rotation matrix = transpose; use `transpose(m)` or `v * m` +- Pre-compute matrices that don't depend on `p` outside `map()` +- Pre-multiply multiple rotations into a single matrix + +**Composition**: +- **SDF / Ray Marching**: Camera generates rays + domain transforms place objects (fundamental pipeline) +- **Noise / fBm**: Rotate sampling coordinates to break axis-aligned regularity `fbm(rot * p)` +- **Fractals / IFS**: Embed rotation in iterations to create complex geometry +- **Lighting**: Normal transform for pure rotation matrices is the same as vertex transform +- **Post-Processing**: FOV for depth of field; `mat2` for chromatic aberration/motion blur direction + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/matrix-transform.md) diff --git a/skills/shader-dev/techniques/multipass-buffer.md b/skills/shader-dev/techniques/multipass-buffer.md new file mode 100644 index 0000000..bb354e6 --- /dev/null +++ b/skills/shader-dev/techniques/multipass-buffer.md @@ -0,0 +1,922 @@ +### Standalone HTML Complete Shader Template (Must Be Strictly Followed) + +**IMPORTANT: The following template can be copied directly; every line must be strictly followed**: + +**Vertex Shader** (common to all shaders): +```glsl +#version 300 es +in vec4 iPosition; +void main() { + gl_Position = iPosition; +} +``` + +**Fragment Shader Buffer A Example** (particle physics simulation): +```glsl +#version 300 es +precision highp float; + +// IMPORTANT: Critical: uniforms must be declared; ShaderToy's iTime/iResolution etc. are global variables +uniform float iTime; +uniform vec2 iResolution; +uniform int iFrame; +uniform vec4 iMouse; + +// IMPORTANT: Critical: mainImage parameters need manual extraction +// ShaderToy: void mainImage(out vec4 fragColor, in vec2 fragCoord) +// Adapted to: +out vec4 fragColor; +void main() { + vec2 fragCoord = gl_FragCoord.xy; + vec2 uv = fragCoord / iResolution; + + // IMPORTANT: Critical: texture2D → texture + vec4 prev = texture(iChannel0, uv); + + // ... particle physics logic ... + + fragColor = vec4(pos, vel); +} +``` + +**Fragment Shader Image Example**: +```glsl +#version 300 es +precision highp float; + +uniform float iTime; +uniform vec2 iResolution; +uniform int iFrame; +uniform vec4 iMouse; +uniform sampler2D iChannel0; + +out vec4 fragColor; + +void main() { + vec2 fragCoord = gl_FragCoord.xy; + vec2 uv = fragCoord / iResolution; + + // IMPORTANT: Critical: texture2D → texture, mainImage → standard main + vec4 col = texture(iChannel0, uv); + + // Rendering logic + col = col / (1.0 + col); // Tone mapping + + fragColor = col; +} +``` + +**IMPORTANT: Common GLSL ES 3.00 Errors** (must be avoided): +1. **#version must be on the first line** - Any comments/blank lines will cause "version directive must occur on the first line" error +2. **in/out qualifiers** - WebGL1's attribute/varying must be changed to in/out in ES3 +3. **texture function** - ES3 uses `texture(sampler, uv)`, not `texture2D(sampler, uv)` +4. **Type strictness** - `vec4 = float` is illegal, must use `vec4(v, v, v, v)` or `vec4(v)` or `vec4(vec3(v), 1.0)` + +## Standalone HTML Multi-Channel Framebuffer Implementation + +**IMPORTANT: Multi-Channel Rendering Pipeline Core Pitfalls**: ShaderToy code requires manual Framebuffer rendering pipeline implementation. The following template demonstrates the correct approach: + +```javascript +// Correct multi-channel Framebuffer creation +const NUM_BUFFERS = 2; // Buffer A, Buffer B +const buffers = []; +const textures = []; + +// Check float texture linear filtering extension +const ext = gl.getExtension('EXT_color_buffer_float'); +const floatLinear = gl.getExtension('OES_texture_float_linear'); + +// Each Buffer needs an independent Framebuffer + texture +for (let i = 0; i < NUM_BUFFERS; i++) { + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + + // IMPORTANT: Critical: Must use UNSIGNED_BYTE format without EXT_color_buffer_float extension! + // RGBA16F/RGBA32F require the extension, otherwise GL_INVALID_OPERATION + // Float textures need EXT_color_buffer_float; RGBA16F supports HDR data + if (ext) { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.FLOAT, null); + } else { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + } + + // IMPORTANT: Critical: Texture parameters must be set, otherwise GL_INVALID_FRAMEBUFFER + // IMPORTANT: Float textures use NEAREST, or require OES_texture_float_linear extension for LINEAR + // IMPORTANT: Critical: Float textures must use CLAMP_TO_EDGE wrap mode; REPEAT is not supported for float textures + // IMPORTANT: Critical: Must fall back to UNSIGNED_BYTE format without EXT_color_buffer_float extension + const filterMode = (ext && floatLinear) ? gl.LINEAR : gl.NEAREST; + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filterMode); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filterMode); + // IMPORTANT: Must use CLAMP_TO_EDGE: float textures do not support REPEAT + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + const fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); + + // IMPORTANT: Critical: Check Framebuffer completeness + const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); + if (status !== gl.FRAMEBUFFER_COMPLETE) { + console.error("Framebuffer incomplete:", status); + } + + textures.push(texture); + buffers.push(fbo); +} +gl.bindFramebuffer(gl.FRAMEBUFFER, null); + +// Render loop: render to Buffer first, then render to screen +function render() { + // 1. Render to Buffer A (self-feedback reads previous Buffer) + gl.bindFramebuffer(gl.FRAMEBUFFER, buffers[0]); + gl.viewport(0, 0, width, height); + // Bind previous frame texture to iChannel0 + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, textures[1]); // Read from other Buffer + // Set uniforms etc... + // Execute shader rendering + + // 2. Swap Buffers (simulate self-feedback) + // IMPORTANT: Critical: Must swap textures for next frame reading; FBO handles remain unchanged + [textures[0], textures[1]] = [textures[1], textures[0]]; + + // 3. Render to screen + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + // Bind Buffer result to texture + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, textures[0]); + // Execute Image pass shader +} +``` + +**IMPORTANT: Common Errors** (JavaScript/WebGL side): +1. **Missing texture parameters** - Must set `TEXTURE_MIN_FILTER`, `TEXTURE_MAG_FILTER`, `TEXTURE_WRAP_S/T` +2. **Missing Framebuffer completeness check** - `gl.checkFramebufferStatus()` must return `FRAMEBUFFER_COMPLETE` before use +3. **Float texture extension** - `gl.RGBA16F` requires `EXT_color_buffer_float` extension, otherwise fall back to `gl.UNSIGNED_BYTE` +4. **Buffer ping-pong error** - Self-feedback must use 2 independent FBOs alternating read/write; a single FBO + texture swap causes "Feedback loop" error +5. **Particle system empty texture initialization** - Textures are empty before the first frame; shaders reading default values cause render failure — must execute initPass() to pre-render + +# Multi-Pass Buffer Techniques + +## Use Cases + +When single-frame computation cannot achieve the desired effect and cross-frame data persistence or multi-stage processing pipelines are needed, use multi-pass buffers: + +- **Temporal accumulation**: Motion blur, TAA, progressive rendering +- **Physics simulation**: Fluids, reaction-diffusion, particle systems +- **Persistent state**: Game state, particle positions/velocities, interaction history +- **Deferred rendering**: G-Buffer → post-processing → compositing +- **Post-processing chains**: HDR Bloom (downsample → blur → composite) +- **Iterative solvers**: Poisson solver, vorticity confinement, multi-scale computation + +## Core Principles + +Multi-pass buffers split the rendering pipeline into multiple Buffers, each outputting a texture as input for the next stage. + +### Self-Feedback +A Buffer reads its own previous frame output, achieving cross-frame state persistence: `x(n+1) = f(x(n))` +``` +Buffer A (frame N) reads → Buffer A (frame N-1) output +``` + +### Pipeline Chaining +Multiple Buffers process in sequence: +``` +Buffer A (geometry) → Buffer B (blur H) → Buffer C (blur V) → Image (compositing) +``` + +### Structured Data Storage +Specific pixels serve as data registers, read precisely via `texelFetch`: +``` +texel (0,0) = ball position+velocity (vec4) +texel (1,0) = paddle position +texel (x,1)-(x,12) = brick grid state +``` + +### Key Mathematical Patterns + +- **Fluid self-advection**: `newPos = texture(buf, uv - dt * velocity * texelSize)` +- **Gaussian blur**: `sum += texture(buf, uv + offset_i) * weight_i` +- **Temporal blending**: `result = mix(newFrame, prevFrame, blendWeight)` +- **Vorticity confinement**: `vortForce = curl × normalize(gradient(|curl|))` + +## Implementation Steps + +### Step 1: Minimal Self-Feedback Loop + +Buffer A (iChannel0 → Buffer A self-feedback): +```glsl +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + + vec4 prev = texture(iChannel0, uv); + + // New content: procedural noise contour lines + float n = noise(vec3(uv * 8.0, 0.1 * iTime)); + float v = sin(6.2832 * 10.0 * n); + v = smoothstep(1.0, 0.0, 0.5 * abs(v) / fwidth(v)); + vec4 newContent = 0.5 + 0.5 * sin(12.0 * n + vec4(0, 2.1, -2.1, 0)); + + // Decay + offset blending + vec4 decayed = exp(-33.0 / iResolution.y) * texture(iChannel0, (fragCoord + vec2(1.0, sin(iTime))) / iResolution.xy); + fragColor = mix(decayed, newContent, v); + + // Initialization guard + if (iFrame < 4) fragColor = vec4(0.5); +} +``` + +Image (iChannel0 → Buffer A): +```glsl +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + fragColor = texture(iChannel0, fragCoord / iResolution.xy); +} +``` + +### Step 2: Fluid Self-Advection + +Buffer A (iChannel0 → Buffer A self-feedback): +```glsl +#define ROT_NUM 5 +#define SCALE_NUM 20 + +const float ang = 6.2832 / float(ROT_NUM); +mat2 m = mat2(cos(ang), sin(ang), -sin(ang), cos(ang)); + +float getRot(vec2 pos, vec2 b) { + vec2 p = b; + float rot = 0.0; + for (int i = 0; i < ROT_NUM; i++) { + rot += dot(texture(iChannel0, fract((pos + p) / iResolution.xy)).xy - vec2(0.5), + p.yx * vec2(1, -1)); + p = m * p; + } + return rot / float(ROT_NUM) / dot(b, b); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 pos = fragCoord; + float rnd = fract(sin(float(iFrame) * 12.9898) * 43758.5453); + vec2 b = vec2(cos(ang * rnd), sin(ang * rnd)); + + // Multi-scale rotation sampling + vec2 v = vec2(0); + float bbMax = 0.7 * iResolution.y; + bbMax *= bbMax; + for (int l = 0; l < SCALE_NUM; l++) { + if (dot(b, b) > bbMax) break; + vec2 p = b; + for (int i = 0; i < ROT_NUM; i++) { + v += p.yx * getRot(pos + p, b); + p = m * p; + } + b *= 2.0; + } + + // Self-advection + fragColor = texture(iChannel0, fract((pos + v * vec2(-1, 1) * 2.0) / iResolution.xy)); + + // Center driving force + vec2 scr = (fragCoord / iResolution.xy) * 2.0 - 1.0; + fragColor.xy += 0.01 * scr / (dot(scr, scr) / 0.1 + 0.3); + + if (iFrame <= 4) fragColor = texture(iChannel1, fragCoord / iResolution.xy); +} +``` + +### Step 3-4: Navier-Stokes Solver + Chained Acceleration + +Buffer A / B / C use identical code (via Common tab's `solveFluid`): +```glsl +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec2 w = 1.0 / iResolution.xy; + + vec4 lastMouse = texelFetch(iChannel0, ivec2(0, 0), 0); + vec4 data = solveFluid(iChannel0, uv, w, iTime, iMouse.xyz, lastMouse.xyz); + + if (iFrame < 20) data = vec4(0.5, 0, 0, 0); + if (fragCoord.y < 1.0) data = iMouse; // Mouse state storage + + fragColor = data; +} +``` + +iChannel bindings: A→C(prev frame), B→A, C→B — 3 iterations per frame. + +### Step 5: Separable Gaussian Blur + +Buffer B (horizontal, iChannel0 → source Buffer) — Buffer C vertical direction is analogous, using y-axis offset: +```glsl +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 pixelSize = 1.0 / iResolution.xy; + vec2 uv = fragCoord * pixelSize; + float h = pixelSize.x; + vec4 sum = vec4(0.0); + // 9-tap Gaussian (sigma ≈ 2.0) + sum += texture(iChannel0, fract(vec2(uv.x - 4.0*h, uv.y))) * 0.05; + sum += texture(iChannel0, fract(vec2(uv.x - 3.0*h, uv.y))) * 0.09; + sum += texture(iChannel0, fract(vec2(uv.x - 2.0*h, uv.y))) * 0.12; + sum += texture(iChannel0, fract(vec2(uv.x - 1.0*h, uv.y))) * 0.15; + sum += texture(iChannel0, fract(vec2(uv.x, uv.y))) * 0.16; + sum += texture(iChannel0, fract(vec2(uv.x + 1.0*h, uv.y))) * 0.15; + sum += texture(iChannel0, fract(vec2(uv.x + 2.0*h, uv.y))) * 0.12; + sum += texture(iChannel0, fract(vec2(uv.x + 3.0*h, uv.y))) * 0.09; + sum += texture(iChannel0, fract(vec2(uv.x + 4.0*h, uv.y))) * 0.05; + fragColor = vec4(sum.xyz / 0.98, 1.0); +} +``` + +### Step 6: Structured State Storage + +```glsl +// Register address definitions +const ivec2 txBallPosVel = ivec2(0, 0); +const ivec2 txPaddlePos = ivec2(1, 0); +const ivec2 txPoints = ivec2(2, 0); +const ivec2 txState = ivec2(3, 0); +const ivec4 txBricks = ivec4(0, 1, 13, 12); + +vec4 loadValue(ivec2 addr) { + return texelFetch(iChannel0, addr, 0); +} + +void storeValue(ivec2 addr, vec4 val, inout vec4 fragColor, ivec2 currentPixel) { + fragColor = (currentPixel == addr) ? val : fragColor; +} + +void storeValue(ivec4 rect, vec4 val, inout vec4 fragColor, ivec2 currentPixel) { + fragColor = (currentPixel.x >= rect.x && currentPixel.y >= rect.y && + currentPixel.x <= rect.z && currentPixel.y <= rect.w) ? val : fragColor; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + ivec2 px = ivec2(fragCoord - 0.5); + if (fragCoord.x > 14.0 || fragCoord.y > 14.0) discard; + + vec4 ballPosVel = loadValue(txBallPosVel); + float paddlePos = loadValue(txPaddlePos).x; + float points = loadValue(txPoints).x; + + if (iFrame == 0) { + ballPosVel = vec4(0.0, -0.8, 0.6, 1.0); + paddlePos = 0.0; + points = 0.0; + } + + // ... game logic update ... + + fragColor = loadValue(px); + storeValue(txBallPosVel, ballPosVel, fragColor, px); + storeValue(txPaddlePos, vec4(paddlePos, 0, 0, 0), fragColor, px); + storeValue(txPoints, vec4(points, 0, 0, 0), fragColor, px); +} +``` + +### Step 7: Mouse State Inter-Frame Tracking + +```glsl +// Method 1: First-row pixel storage +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec2 w = 1.0 / iResolution.xy; + vec4 lastMouse = texelFetch(iChannel0, ivec2(0, 0), 0); + // ... simulation logic ... + if (fragCoord.y < 1.0) fragColor = iMouse; +} + +// Method 2: Fixed UV region storage +vec2 mouseDelta() { + vec2 pixelSize = 1.0 / iResolution.xy; + float eighth = 1.0 / 8.0; + vec4 oldMouse = texture(iChannel2, vec2(7.5 * eighth, 2.5 * eighth)); + vec4 nowMouse = vec4(iMouse.xy / iResolution.xy, iMouse.zw / iResolution.xy); + if (oldMouse.z > pixelSize.x && oldMouse.w > pixelSize.y && + nowMouse.z > pixelSize.x && nowMouse.w > pixelSize.y) { + return nowMouse.xy - oldMouse.xy; + } + return vec2(0.0); +} +``` + +## Complete Code Template + +A fully runnable fluid simulation shader (self-feedback + vorticity confinement + mouse interaction + color advection). + +### Common tab + +```glsl +#define DT 0.15 +#define VORTICITY_AMOUNT 0.11 +#define VISCOSITY 0.55 +#define PRESSURE_K 0.2 +#define FORCE_RADIUS 0.001 +#define FORCE_STRENGTH 0.001 +#define VELOCITY_DECAY 1e-4 + +float mag2(vec2 p) { return dot(p, p); } + +vec2 emitter1(float t) { t *= 0.62; return vec2(0.12, 0.5 + sin(t) * 0.2); } +vec2 emitter2(float t) { t *= 0.62; return vec2(0.88, 0.5 + cos(t + 1.5708) * 0.2); } + +vec4 solveFluid(sampler2D smp, vec2 uv, vec2 w, float time, vec3 mouse, vec3 lastMouse) { + vec4 data = textureLod(smp, uv, 0.0); + vec4 tr = textureLod(smp, uv + vec2(w.x, 0), 0.0); + vec4 tl = textureLod(smp, uv - vec2(w.x, 0), 0.0); + vec4 tu = textureLod(smp, uv + vec2(0, w.y), 0.0); + vec4 td = textureLod(smp, uv - vec2(0, w.y), 0.0); + + vec3 dx = (tr.xyz - tl.xyz) * 0.5; + vec3 dy = (tu.xyz - td.xyz) * 0.5; + vec2 densDif = vec2(dx.z, dy.z); + + data.z -= DT * dot(vec3(densDif, dx.x + dy.y), data.xyz); + + vec2 laplacian = tu.xy + td.xy + tr.xy + tl.xy - 4.0 * data.xy; + vec2 viscForce = vec2(VISCOSITY) * laplacian; + + data.xyw = textureLod(smp, uv - DT * data.xy * w, 0.0).xyw; + + vec2 newForce = vec2(0); + newForce += 0.75 * vec2(0.0003, 0.00015) / (mag2(uv - emitter1(time)) + 0.0001); + newForce -= 0.75 * vec2(0.0003, 0.00015) / (mag2(uv - emitter2(time)) + 0.0001); + + if (mouse.z > 1.0 && lastMouse.z > 1.0) { + vec2 vv = clamp((mouse.xy * w - lastMouse.xy * w) * 400.0, -6.0, 6.0); + newForce += FORCE_STRENGTH / (mag2(uv - mouse.xy * w) + FORCE_RADIUS) * vv; + } + + data.xy += DT * (viscForce - PRESSURE_K / DT * densDif + newForce); + data.xy = max(vec2(0), abs(data.xy) - VELOCITY_DECAY) * sign(data.xy); + + data.w = (tr.y - tl.y - tu.x + td.x); + vec2 vort = vec2(abs(tu.w) - abs(td.w), abs(tl.w) - abs(tr.w)); + vort *= VORTICITY_AMOUNT / length(vort + 1e-9) * data.w; + data.xy += vort; + + data.y *= smoothstep(0.5, 0.48, abs(uv.y - 0.5)); + data = clamp(data, vec4(vec2(-10), 0.5, -10.0), vec4(vec2(10), 3.0, 10.0)); + + return data; +} +``` + +### Buffer A / B / C (Fluid Sub-Steps 1/2/3) + +iChannel bindings: A←C(prev frame), B←A, C←B + +```glsl +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec2 w = 1.0 / iResolution.xy; + vec4 lastMouse = texelFetch(iChannel0, ivec2(0, 0), 0); + vec4 data = solveFluid(iChannel0, uv, w, iTime, iMouse.xyz, lastMouse.xyz); + if (iFrame < 20) data = vec4(0.5, 0, 0, 0); + if (fragCoord.y < 1.0) data = iMouse; + fragColor = data; +} +``` + +### Buffer D (Color Advection, iChannel0 → Buffer C, iChannel1 → Buffer D self-feedback) + +```glsl +#define COLOR_DECAY 0.004 +#define COLOR_ADVECT_SCALE 3.0 + +vec3 getPalette(float x, vec3 c1, vec3 c2, vec3 p1, vec3 p2) { + float x2 = fract(x / 2.0); + x = fract(x); + mat3 m = mat3(c1, p1, c2); + mat3 m2 = mat3(c2, p2, c1); + float omx = 1.0 - x; + vec3 pws = vec3(omx * omx, 2.0 * omx * x, x * x); + return clamp(mix(m * pws, m2 * pws, step(x2, 0.5)), 0.0, 1.0); +} + +vec4 palette1(float x) { + return vec4(getPalette(-x, vec3(0.2, 0.5, 0.7), vec3(0.9, 0.4, 0.1), + vec3(1.0, 1.2, 0.5), vec3(1.0, -0.4, 0.0)), 1.0); +} +vec4 palette2(float x) { + return vec4(getPalette(-x, vec3(0.4, 0.3, 0.5), vec3(0.9, 0.75, 0.4), + vec3(0.1, 0.8, 1.3), vec3(1.25, -0.1, 0.1)), 1.0); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec2 w = 1.0 / iResolution.xy; + + vec2 velo = textureLod(iChannel0, uv, 0.0).xy; + vec4 col = textureLod(iChannel1, uv - DT * velo * w * COLOR_ADVECT_SCALE, 0.0); + + vec2 mo = iMouse.xy / iResolution.xy; + vec4 lastMouse = texelFetch(iChannel1, ivec2(0, 0), 0); + if (iMouse.z > 1.0 && lastMouse.z > 1.0) { + float str = smoothstep(-0.5, 1.0, length(mo - lastMouse.xy / iResolution.xy)); + col += str * 0.0009 / (pow(length(uv - mo), 1.7) + 0.002) * palette2(-iTime * 0.7); + } + + col += 0.0025 / (0.0005 + pow(length(uv - emitter1(iTime)), 1.75)) * DT * 0.12 * palette1(iTime * 0.05); + col += 0.0025 / (0.0005 + pow(length(uv - emitter2(iTime)), 1.75)) * DT * 0.12 * palette2(iTime * 0.05 + 0.675); + + if (iFrame < 20) col = vec4(0.0); + col = clamp(col, 0.0, 5.0); + col = max(col - (0.0001 + col * COLOR_DECAY) * 0.5, 0.0); + + if (fragCoord.y < 1.0 && fragCoord.x < 1.0) col = iMouse; + fragColor = col; +} +``` + +### Image (iChannel0 → Buffer D) + +```glsl +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec4 col = textureLod(iChannel0, fragCoord / iResolution.xy, 0.0); + if (fragCoord.y < 1.0 || fragCoord.y >= iResolution.y - 1.0) col = vec4(0); + fragColor = col; +} +``` + +## Common Variants + +### Variant 1: TAA Temporal Accumulation Anti-Aliasing + +```glsl +// Buffer A: Sub-pixel jittered rendering +vec2 jitter = vec2(rand(uv + sin(iTime)), rand(uv + 1.0 + sin(iTime))) / iResolution.xy; +vec3 eyevec = normalize(vec3(((uv + jitter) * 2.0 - 1.0) * vec2(aspect, 1.0), fov)); +float blendWeight = 0.9; +color = mix(color, texture(iChannel_self, uv).rgb, blendWeight); + +// Buffer C (TAA): YCoCg neighborhood clamping to prevent ghosting +vec3 newYCC = RGBToYCoCg(newFrame); +vec3 histYCC = RGBToYCoCg(history); +vec3 colorAvg = ...; vec3 colorVar = ...; +vec3 sigma = sqrt(max(vec3(0), colorVar - colorAvg * colorAvg)); +histYCC = clamp(histYCC, colorAvg - 0.75 * sigma, colorAvg + 0.75 * sigma); +result = YCoCgToRGB(mix(newYCC, histYCC, 0.95)); +``` + +### Variant 2: Deferred Rendering G-Buffer + +```glsl +// Buffer A: G-Buffer output +col.xy = (normal * camMat * 0.5 + 0.5).xy; // Normal +col.z = 1.0 - abs((t * rd) * camMat).z / DMAX; // Depth +col.w = dot(lightDir, nor) * 0.5 + 0.5; // Diffuse + +// Buffer B: Edge detection +float checkSame(vec4 center, vec4 sample) { + vec2 diffNormal = abs(center.xy - sample.xy) * Sensitivity.x; + float diffDepth = abs(center.z - sample.z) * Sensitivity.y; + return (diffNormal.x + diffNormal.y < 0.1 && diffDepth < 0.1) ? 1.0 : 0.0; +} +``` + +### Variant 3: HDR Bloom + +```glsl +// Buffer B: MIP pyramid (multi-level downsampling packed into one texture) +vec2 CalcOffset(float octave) { + vec2 offset = vec2(0); + vec2 padding = vec2(10.0) / iResolution.xy; + offset.x = -min(1.0, floor(octave / 3.0)) * (0.25 + padding.x); + offset.y = -(1.0 - 1.0 / exp2(octave)) - padding.y * octave; + offset.y += min(1.0, floor(octave / 3.0)) * 0.35; + return offset; +} +// Image: Accumulate multi-level bloom + Reinhard tone mapping +bloom += Grab(coord, 1.0, CalcOffset(0.0)) * 1.0; +bloom += Grab(coord, 2.0, CalcOffset(1.0)) * 1.5; +color = pow(color, vec3(1.5)); +color = color / (1.0 + color); +``` + +### Variant 4: Reaction-Diffusion System + +```glsl +// Buffer A: Gray-Scott reaction-diffusion +vec2 uv_red = uv + vec2(dx.x, dy.x) * pixelSize * 8.0; +float new_val = texture(iChannel0, fract(uv_red)).x; +new_val += (noise.x - 0.5) * 0.0025 - 0.002; +new_val -= (texture(iChannel_blur, fract(uv_red)).x - + texture(iChannel_self, fract(uv_red)).x) * 0.047; +``` + +### Variant 5: Multi-Scale MIP Fluid + +```glsl +for (int i = 0; i < NUM_SCALES; i++) { + float mip = float(i); + float stride = float(1 << i); + vec4 t = stride * vec4(texel, -texel.y, 0); + vec2 d = textureLod(sampler, fract(uv + t.ww), mip).xy; + float w = WEIGHT_FUNCTION; + result += w * computation(neighbors); +} +``` + +### Variant 6: Particle System (Position-Velocity Storage) + +**IMPORTANT: Particle System Implementation Key**: Particle state is stored in texture pixels, one particle per pixel. Rendering must iterate over the particle texture for sampling. + +**Buffer A (Particle Physics Simulation)**: +```glsl +// Each texture pixel stores one particle: xy=position, zw=velocity + +// IMPORTANT: Critical: hash function must return vec2! Returning float causes type mismatch errors +vec2 hash2(vec2 p) { + return fract(sin(mat2(127.1, 311.7, 269.5, 183.3) * p) * 43758.5453); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec4 prev = texture(iChannel0, uv); + + vec2 pos = prev.xy; + vec2 vel = prev.zw; + + // IMPORTANT: Initialization guard: use integer comparison + pixel-coordinate-based random (avoids particle overlap when time is too small) + if (iFrame < 3) { + // Use fragCoord (pixel coordinates) to ensure each particle has a unique position, independent of time + // IMPORTANT: Critical: hash2 returns vec2, assign directly to pos/vel + pos = hash2(fragCoord * 0.01 + vec2(1.7, 9.3)); + vel = (hash2(fragCoord * 0.01 + vec2(5.3, 2.8)) - 0.5) * 0.02; + fragColor = vec4(pos, vel); + return; + } + + // Physics update + vel *= 0.98; // Damping + + // Mouse interaction + vec2 mouse = iMouse.xy / iResolution.xy; + if (iMouse.z > 0.0) { + vec2 toMouse = mouse - pos; + vel += normalize(toMouse + 0.001) * 0.0005 / (length(toMouse) + 0.1); + } + + // Motion + pos += vel * 60.0 * 0.016; + pos = fract(pos); // Boundary wrapping + + fragColor = vec4(pos, vel); +} +``` + +**Image (Render Particles)**: +```glsl +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec2 w = 1.0 / iResolution.xy; + + vec3 color = vec3(0.02, 0.02, 0.05); // Dark background + + // Iterate over particle texture for sampling (performance-sensitive, balance sample count) + float glow = 0.0; + for (float y = 0.0; y < 1.0; y += 0.02) { // IMPORTANT: Step size determines sampling density + for (float x = 0.0; x < 1.0; x += 0.02) { + vec4 particle = texture(iChannel0, vec2(x, y)); + vec2 pPos = particle.xy; + float dist = length(uv - pPos); + float size = 0.01 + length(particle.zw) * 0.3; + glow += exp(-dist * dist / (size * size)) * 0.15; + } + } + + // Particle glow + color += vec3(0.3, 0.6, 1.0) * glow; + + // Vignette + color *= 1.0 - length(uv - 0.5) * 0.8; + + // Tone mapping + color = color / (1.0 + color); + + fragColor = vec4(color, 1.0); +} +``` + +**Key Points**: +- Buffer A self-feedback: iChannel0 → Buffer A +- Image reads: iChannel0 → Buffer A (particle state) +- Step size 0.02 produces 2500 samples; adjust based on performance +- Particle size varies with velocity: `size = 0.01 + length(vel) * 0.3` + +**Complete JavaScript Rendering Pipeline (Particle System 3-Pass)**: +```javascript +// Particle system needs 4 Framebuffers (2 each for Buffer A and Buffer B ping-pong) + screen output +// Buffer A: Particle physics (self-feedback) - uses FBO 0/1 ping-pong +// Buffer B: Density accumulation (reads Buffer A) - uses FBO 2/3 ping-pong +// Image: Final rendering (reads Buffer A + Buffer B) + +// IMPORTANT: Critical: Must use 2 FBOs for ping-pong! Single FBO + texture swap causes +// "Feedback loop formed between Framebuffer and active Texture" error +const buffers = [null, null, null, null]; // [A_FBO0, A_FBO1, B_FBO0, B_FBO1] +const textures = [null, null, null, null]; // [A_tex0, A_tex1, B_tex0, B_tex1] + +function createBuffers() { + // Buffer A: 2 FBOs for ping-pong + for (let i = 0; i < 2; i++) { + const tex = createTexture(); + textures[i] = tex; + + const fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); + buffers[i] = fbo; + } + // Buffer B: 2 FBOs for ping-pong + for (let i = 0; i < 2; i++) { + const tex = createTexture(); + textures[2 + i] = tex; + + const fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); + buffers[2 + i] = fbo; + } + gl.bindFramebuffer(gl.FRAMEBUFFER, null); +} + +// IMPORTANT: Critical: Initialization pre-rendering - must execute before the first frame! +// Empty textures cause particle initialization failure (reading 0,0,0,0 makes all particles overlap) +let aReadIdx = 0; // Current read FBO index (0 or 1) +let bReadIdx = 0; // Buffer B current read FBO index (0 or 1) + +function initPass() { + // ===== Buffer A Initialization ===== + // Render first frame using FBO 0 + gl.bindFramebuffer(gl.FRAMEBUFFER, buffers[0]); + gl.viewport(0, 0, width, height); + gl.useProgram(programBufferA); + setupAttribute(programBufferA); + // Bind FBO 1's texture as input (not yet rendered, but avoids binding errors) + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, textures[1]); + gl.uniform1i(gl.getUniformLocation(programBufferA, 'iChannel0'), 0); + gl.uniform2f(gl.getUniformLocation(programBufferA, 'iResolution'), width, height); + gl.uniform1f(gl.getUniformLocation(programBufferA, 'iTime'), 0); + gl.uniform1i(gl.getUniformLocation(programBufferA, 'iFrame'), 0); + gl.uniform4f(gl.getUniformLocation(programBufferA, 'iMouse'), 0, 0, 0, 0); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + // Render second frame using FBO 1 (iFrame=1) + gl.bindFramebuffer(gl.FRAMEBUFFER, buffers[1]); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, textures[0]); // Read FBO 0's result + gl.uniform1i(gl.getUniformLocation(programBufferA, 'iFrame'), 1); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + // Render one more frame to ensure initialization is complete + gl.bindFramebuffer(gl.FRAMEBUFFER, buffers[0]); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, textures[1]); + gl.uniform1i(gl.getUniformLocation(programBufferA, 'iFrame'), 2); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + // ===== Buffer B Initialization ===== + gl.bindFramebuffer(gl.FRAMEBUFFER, buffers[2]); // B_FBO0 + gl.viewport(0, 0, width, height); + gl.useProgram(programBufferB); + setupAttribute(programBufferB); + + // Bind latest Buffer A result (FBO 0's result) + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, textures[0]); + gl.uniform1i(gl.getUniformLocation(programBufferB, 'iChannel0'), 0); + + // Bind Buffer B previous frame (FBO 3's texture, not yet rendered) + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, textures[3]); + gl.uniform1i(gl.getUniformLocation(programBufferB, 'iChannel1'), 1); + + gl.uniform2f(gl.getUniformLocation(programBufferB, 'iResolution'), width, height); + gl.uniform1f(gl.getUniformLocation(programBufferB, 'iTime'), 0); + gl.uniform1i(gl.getUniformLocation(programBufferB, 'iFrame'), 0); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + // Buffer B second frame + gl.bindFramebuffer(gl.FRAMEBUFFER, buffers[3]); // B_FBO1 + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, textures[1]); // Buffer A latest + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, textures[2]); // Buffer B FBO0 result + gl.uniform1i(gl.getUniformLocation(programBufferB, 'iFrame'), 1); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + // Initialize ping-pong indices + aReadIdx = 0; // Next frame reads FBO 0 + bReadIdx = 0; // Next frame reads FBO 2 + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); +} + +function render() { + // ===== Pass 1: Buffer A (Particle Physics Self-Feedback) ===== + // aReadIdx = 0: read FBO 0, write FBO 1 + // aReadIdx = 1: read FBO 1, write FBO 0 + const aWriteIdx = 1 - aReadIdx; + + // Write to target FBO (not the current read FBO) + gl.bindFramebuffer(gl.FRAMEBUFFER, buffers[aWriteIdx]); + gl.viewport(0, 0, width, height); + + // Read previous frame Buffer A texture (from current read FBO's texture) + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, textures[aReadIdx]); + gl.uniform1i(uniformsBufferA.iChannel0, 0); + + gl.uniform2f(uniformsBufferA.iResolution, width, height); + gl.uniform1f(uniformsBufferA.iTime, time); + gl.uniform1i(uniformsBufferA.iFrame, frameCount); + gl.uniform4f(uniformsBufferA.iMouse, mouse.x, mouse.y, mouse.z, mouse.w); + + // Render particle physics + gl.useProgram(programBufferA); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + // Switch read index + aReadIdx = aWriteIdx; + + // ===== Pass 2: Buffer B (Density Field) ===== + const bWriteIdx = 1 - bReadIdx; + + gl.bindFramebuffer(gl.FRAMEBUFFER, buffers[2 + bWriteIdx]); // B_FBO0 or B_FBO1 + gl.viewport(0, 0, width, height); + + // Bind current Buffer A particle state (use latest Buffer A result) + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, textures[aReadIdx]); // A latest result + gl.uniform1i(uniformsBufferB.iChannel0, 0); + + // Bind previous frame Buffer B density (for accumulation) + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, textures[2 + bReadIdx]); // B_read + gl.uniform1i(uniformsBufferB.iChannel1, 1); + + gl.uniform2f(uniformsBufferB.iResolution, width, height); + gl.uniform1f(uniformsBufferB.iTime, time); + gl.uniform1i(uniformsBufferB.iFrame, frameCount); + + // Render density accumulation + gl.useProgram(programBufferB); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + // Switch Buffer B read index + bReadIdx = bWriteIdx; + + // ===== Pass 3: Image (Final Rendering to Screen) ===== + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, width, height); + + // Bind Buffer A particles (use latest Buffer A result) + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, textures[aReadIdx]); + gl.uniform1i(uniformsImage.iChannel0, 0); + + // Bind Buffer B density (use latest Buffer B result) + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, textures[2 + bReadIdx]); + gl.uniform1i(uniformsImage.iChannel1, 1); + + gl.uniform2f(uniformsImage.iResolution, width, height); + gl.uniform1f(uniformsImage.iTime, time); + gl.uniform1i(uniformsImage.iFrame, frameCount); + gl.uniform4f(uniformsImage.iMouse, mouse.x, mouse.y, mouse.z, mouse.w); + + // Render to screen + gl.useProgram(programImage); + gl.drawArrays(gl.TRIANGLES, 0, 6); +} +``` + +**IMPORTANT: Key Points**: +- **Must use 2 FBOs for ping-pong**: Each Buffer needs two independent FBOs (read FBO + write FBO); a single FBO + texture swap causes "Feedback loop" error +- Use FBO index switching (not texture swapping): bind target FBO when writing, bind source texture when reading +- Image pass binds the latest Buffer results (obtained via read index) + +## Performance & Composition + +**Performance Optimization**: +- Separable blur: N² → 2N samples +- Bilinear tap trick: 5 samples replace 9-tap Gaussian +- MIP sampling replaces large kernels: `textureLod` at high MIP levels ≈ large-range average +- `discard` outside data regions to skip unnecessary computation +- RGBA channel packing: velocity(xy) + density(z) + curl(w) in one vec4 +- Chained sub-steps: A→B→C same code for 3x simulation speed +- `if (dot(b,b) > bbMax) break;` adaptive early exit +- `iFrame < 20` progressive initialization to prevent explosion + +**Typical Composition Patterns**: +- **Fluid + Lighting**: Fluid buffer → Image computes gradient normals → diffuse + specular +- **Fluid + Color Advection**: Separate Buffer tracks color field, advected by velocity field +- **Scene + Bloom + TAA**: 4-Buffer pipeline (render → downsample → blur → composite tone mapping) +- **G-Buffer + Screen-Space Effects**: 2-Buffer without temporal feedback (geometry → edge/SSAO/SSR → stylized compositing) +- **State Storage + Visualization Separation**: Buffer A pure logic + Image pure rendering (`texelFetch` reads state + distance field drawing) + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/multipass-buffer.md) diff --git a/skills/shader-dev/techniques/normal-estimation.md b/skills/shader-dev/techniques/normal-estimation.md new file mode 100644 index 0000000..7434ea2 --- /dev/null +++ b/skills/shader-dev/techniques/normal-estimation.md @@ -0,0 +1,318 @@ +## WebGL2 Adaptation Requirements + +**IMPORTANT: GLSL Type Strictness Warning**: +- GLSL is a strongly typed language with **no `string` type**; using string types is forbidden +- Common illegal types: `string`, `int` (can only use `int` literals, cannot declare variable types as `int`) +- vec2/vec3/vec4 cannot be implicitly converted between each other; explicit construction is required +- Float precision: `highp float` (recommended), `mediump float`, `lowp float` + +The code templates in this document use ShaderToy GLSL style. When generating standalone HTML pages, you must adapt for WebGL2: + +- Use `canvas.getContext("webgl2")` +- Shader first line: `#version 300 es`, add `precision highp float;` in fragment shader +- Vertex shader: `attribute` -> `in`, `varying` -> `out` +- Fragment shader: `varying` -> `in`, `gl_FragColor` -> custom `out vec4 fragColor`, `texture2D()` -> `texture()` +- ShaderToy's `void mainImage(out vec4 fragColor, in vec2 fragCoord)` must be adapted to the standard `void main()` entry point + +# SDF Normal Estimation + +## Use Cases + +- Lighting calculations in raymarching rendering pipelines (diffuse, specular, Fresnel, etc.) +- Any 3D scene based on SDF distance fields (fractals, parametric surfaces, boolean geometry, procedural terrain) +- Edge detection and contour rendering (Laplacian value as a byproduct of normal sampling) +- Prerequisite for ambient occlusion (AO) computation + +## Core Principles + +The gradient of an SDF `nabla f(p)` points in the direction of fastest distance increase, which is the outward surface normal. Numerical differentiation approximates the gradient: + +$$\vec{n} = \text{normalize}\left(\nabla f(p)\right)$$ + +Three main strategies: + +| Method | Samples | Accuracy | Recommendation | +|--------|---------|----------|----------------| +| Forward difference | 4 | O(epsilon) | Simple scenes | +| Central difference | 6 | O(epsilon^2) | When symmetry is needed | +| **Tetrahedron method** | **4** | **Between the two** | **Preferred** | + +Key parameter epsilon: commonly `0.0005 ~ 0.001`; for advanced scenes, multiply by ray distance `t` for adaptive scaling. + +## Implementation Steps + +### Step 1: Define SDF Scene Function + +```glsl +float map(vec3 p) { + float d = length(p) - 1.0; // unit sphere + return d; +} +``` + +### Step 2: Choose Differentiation Method + +#### Method A: Forward Difference -- 4 Samples + +```glsl +const float EPSILON = 1e-3; + +vec3 getNormal(vec3 p) { + vec3 n; + n.x = map(vec3(p.x + EPSILON, p.y, p.z)); + n.y = map(vec3(p.x, p.y + EPSILON, p.z)); + n.z = map(vec3(p.x, p.y, p.z + EPSILON)); + return normalize(n - map(p)); +} +``` + +#### Method B: Central Difference -- 6 Samples + +```glsl +vec3 getNormal(vec3 p) { + vec2 o = vec2(0.001, 0.0); + return normalize(vec3( + map(p + o.xyy) - map(p - o.xyy), + map(p + o.yxy) - map(p - o.yxy), + map(p + o.yyx) - map(p - o.yyx) + )); +} +``` + +#### Method C: Tetrahedron Method -- 4 Samples (Recommended) + +```glsl +// Classic tetrahedron method, coefficient 0.5773 ~ 1/sqrt(3) +vec3 calcNormal(vec3 pos) { + float eps = 0.0005; + vec2 e = vec2(1.0, -1.0) * 0.5773; + return normalize( + e.xyy * map(pos + e.xyy * eps) + + e.yyx * map(pos + e.yyx * eps) + + e.yxy * map(pos + e.yxy * eps) + + e.xxx * map(pos + e.xxx * eps) + ); +} +``` + +### Step 3: Apply to Lighting + +```glsl +vec3 pos = ro + rd * t; // hit point +vec3 nor = calcNormal(pos); // surface normal + +vec3 lightDir = normalize(vec3(1.0, 4.0, -4.0)); +float diff = max(dot(nor, lightDir), 0.0); +vec3 col = vec3(0.8) * diff; +``` + +## Complete Code Template + +```glsl +// SDF Normal Estimation — Complete ShaderToy Template + +#define MAX_STEPS 128 +#define MAX_DIST 100.0 +#define SURF_DIST 0.001 +#define NORMAL_METHOD 2 // 0=forward diff, 1=central diff, 2=tetrahedron + +// ---- SDF Scene Definition ---- +float map(vec3 p) { + float sphere = length(p - vec3(0.0, 1.0, 0.0)) - 1.0; + float ground = p.y; + return min(sphere, ground); +} + +// ---- Normal Estimation ---- + +vec3 normalForward(vec3 p) { + float eps = 0.001; + float d = map(p); + return normalize(vec3( + map(p + vec3(eps, 0.0, 0.0)), + map(p + vec3(0.0, eps, 0.0)), + map(p + vec3(0.0, 0.0, eps)) + ) - d); +} + +vec3 normalCentral(vec3 p) { + vec2 e = vec2(0.001, 0.0); + return normalize(vec3( + map(p + e.xyy) - map(p - e.xyy), + map(p + e.yxy) - map(p - e.yxy), + map(p + e.yyx) - map(p - e.yyx) + )); +} + +vec3 normalTetra(vec3 p) { + float eps = 0.0005; + vec2 e = vec2(1.0, -1.0) * 0.5773; + return normalize( + e.xyy * map(p + e.xyy * eps) + + e.yyx * map(p + e.yyx * eps) + + e.yxy * map(p + e.yxy * eps) + + e.xxx * map(p + e.xxx * eps) + ); +} + +vec3 calcNormal(vec3 p) { +#if NORMAL_METHOD == 0 + return normalForward(p); +#elif NORMAL_METHOD == 1 + return normalCentral(p); +#else + return normalTetra(p); +#endif +} + +// ---- Raymarching ---- +float raymarch(vec3 ro, vec3 rd) { + float t = 0.0; + for (int i = 0; i < MAX_STEPS; i++) { + vec3 p = ro + rd * t; + float d = map(p); + if (d < SURF_DIST || t > MAX_DIST) break; + t += d; + } + return t; +} + +// ---- Main Function ---- +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; + + vec3 ro = vec3(0.0, 2.0, -5.0); + vec3 rd = normalize(vec3(uv, 1.5)); + + float t = raymarch(ro, rd); + vec3 col = vec3(0.0); + + if (t < MAX_DIST) { + vec3 pos = ro + rd * t; + vec3 nor = calcNormal(pos); + + vec3 sunDir = normalize(vec3(0.8, 0.4, -0.6)); + float diff = clamp(dot(nor, sunDir), 0.0, 1.0); + float amb = 0.5 + 0.5 * nor.y; + vec3 ref = reflect(rd, nor); + float spec = pow(clamp(dot(ref, sunDir), 0.0, 1.0), 16.0); + + col = vec3(0.18) * amb + vec3(1.0, 0.95, 0.85) * diff + vec3(0.5) * spec; + } else { + col = vec3(0.5, 0.7, 1.0) - 0.5 * rd.y; + } + + col = pow(col, vec3(0.4545)); + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Variant 1: NuSan Reverse-Offset Forward Difference + +```glsl +// Reverse-offset forward difference +vec2 noff = vec2(0.001, 0.0); +vec3 normal = normalize( + map(pos) - vec3( + map(pos - noff.xyy), + map(pos - noff.yxy), + map(pos - noff.yyx) + ) +); +``` + +### Variant 2: Adaptive Epsilon (Distance Scaling) + +```glsl +// Adaptive epsilon based on ray distance +vec3 calcNormal(vec3 pos, float t) { + float precis = 0.001 * t; + vec2 e = vec2(1.0, -1.0) * precis; + return normalize( + e.xyy * map(pos + e.xyy) + + e.yyx * map(pos + e.yyx) + + e.yxy * map(pos + e.yxy) + + e.xxx * map(pos + e.xxx) + ); +} +``` + +### Variant 3: Large Epsilon for Rounding / Anti-Aliasing + +```glsl +// Large epsilon for rounding / anti-aliasing +vec3 getNormal(vec3 p) { + vec2 e = vec2(0.015, -0.015); // intentionally large epsilon + return normalize( + e.xyy * map(p + e.xyy) + + e.yyx * map(p + e.yyx) + + e.yxy * map(p + e.yxy) + + e.xxx * map(p + e.xxx) + ); +} +``` + +### Variant 4: Anti-Inlining Loop + +```glsl +// Anti-inlining loop — reduces compile time for complex SDFs +#define ZERO (min(iFrame, 0)) + +vec3 calcNormal(vec3 p, float t) { + vec3 n = vec3(0.0); + for (int i = ZERO; i < 4; i++) { + vec3 e = 0.5773 * (2.0 * vec3( + (((i + 3) >> 1) & 1), + ((i >> 1) & 1), + (i & 1) + ) - 1.0); + n += e * map(p + e * 0.001 * t); + } + return normalize(n); +} +``` + +### Variant 5: Normal + Edge Detection + +```glsl +// Central difference + Laplacian edge detection +float edge = 0.0; +vec3 normal(vec3 p) { + vec3 e = vec3(0.0, det * 5.0, 0.0); + + float d1 = de(p - e.yxx), d2 = de(p + e.yxx); + float d3 = de(p - e.xyx), d4 = de(p + e.xyx); + float d5 = de(p - e.xxy), d6 = de(p + e.xxy); + float d = de(p); + + edge = abs(d - 0.5 * (d2 + d1)) + + abs(d - 0.5 * (d4 + d3)) + + abs(d - 0.5 * (d6 + d5)); + edge = min(1.0, pow(edge, 0.55) * 15.0); + + return normalize(vec3(d1 - d2, d3 - d4, d5 - d6)); +} +``` + +## Performance & Composition + +**Performance**: +- Default to tetrahedron method (4 samples, better accuracy than forward difference) +- Only switch to central difference (6 samples) when jagged normal artifacts appear +- Use anti-inlining loop (Variant 4) for complex SDFs to avoid compile time explosion +- Epsilon recommended `0.0005 ~ 0.001`; best practice is adaptive `eps * t` +- Too small (< 1e-5) produces floating-point noise; too large (> 0.05) loses detail +- Reuse SDF sampling results when multiple types of information are needed at the same position (e.g., Variant 5) + +**Common combinations**: +- **Normal + Soft Shadow**: `calcSoftShadow(pos + nor * 0.01, sunDir, 16.0)` -- normal offset at start point to avoid self-intersection +- **Normal + AO**: Multi-step SDF sampling along the normal to estimate occlusion +- **Normal + Fresnel**: `pow(clamp(1.0 + dot(nor, rd), 0.0, 1.0), 5.0)` +- **Normal + Bump Mapping**: Overlay texture gradient perturbation on SDF normals +- **Normal + Triplanar Mapping**: Use `abs(nor)` components as triplanar blend weights + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/normal-estimation.md) diff --git a/skills/shader-dev/techniques/particle-system.md b/skills/shader-dev/techniques/particle-system.md new file mode 100644 index 0000000..30b0f77 --- /dev/null +++ b/skills/shader-dev/techniques/particle-system.md @@ -0,0 +1,1203 @@ +# Particle System + +**IMPORTANT: GLSL ES 3.0 return values**: All code paths in non-void functions must return a value. Every branch of an if statement must have a return. + +**IMPORTANT: Particle system time cycling**: Must use `mod(time - offset, period)` for cycle computation. **Never use `floor(time / period) * period`**! The latter causes all particles to have negative time initially, resulting in startup delay or blank rendering. + +**IMPORTANT: Brightness budget (most common failure cause!)**: Each particle's `numerator / (dist² + epsilon)` peak = `numerator / epsilon`. **Total peak = N_particles x (numerator / epsilon) must be < 5.0** (single pass). Exceeding this budget causes washout even with Reinhard. See specific reference values in the comments of each template below. Multi-pass ping-pong systems have a stricter budget, see below. + +**IMPORTANT: Particle color vs background contrast**: When particle color is close to the background (sand dust/snow/fog), visibility must be enhanced through at least one method: (1) brightness significantly higher than background (2) different hue (3) visible motion trail. + +**IMPORTANT: Elongated glow (meteor/trail lines)**: Do not use `1/(distPerp² + tiny_epsilon)` for line glow — too-small epsilon makes the line center extremely bright. Correct approach: use `smoothstep` or `exp(-dist)` for lines, see meteor template below. + +### Correct Pattern +```glsl +// Vertex shader +#version 300 es +in vec4 aPosition; +void main() { + gl_Position = aPosition; +} + +// Fragment shader +#version 300 es +precision highp float; +uniform vec2 iResolution; +uniform float iTime; +out vec4 fragColor; + +float hash11(float p) { + return fract(sin(p * 127.1) * 43758.5453); +} + +void main() { + vec2 uv = gl_FragCoord.xy / iResolution.xy; + // ... particle system logic + fragColor = vec4(col, 1.0); +} +``` + +### Complete Single-File Template (Fireworks Particle System) + +```html + + + + + Fireworks + + + + + + + +``` + +### Stateful Particle System HTML Template (Multi-Pass Ping-Pong) + +Stateful particles (Boids, cloth, fluid particles, etc.) need Buffers for inter-frame state storage. The following JS skeleton demonstrates the correct WebGL2 multi-pass ping-pong structure; shader code is in Steps 4-5 below: + +```html + + + + + Stateful Particles + + + + + + + +``` + +### Star Field Function Template (For Meteors, Fireworks, and Other Night Sky Scenes) + +**IMPORTANT: Star visibility**: Stars must be clearly visible in screenshots as individual light points. Use `exp(-dist*dist*k)` for sharp Gaussian dots rather than `1/(dist²+eps)` broad glow. Each star's peak brightness should be at least 0.3 to be visible against a dark background. + +```glsl +#define NUM_STARS 200 + +vec3 starField(vec2 uv) { + vec3 col = vec3(0.0); + for (int i = 0; i < NUM_STARS; i++) { + float fi = float(i); + vec2 starPos = vec2(hash11(fi * 13.7 + 1.3), hash11(fi * 7.3 + 91.1)); + starPos = starPos * 2.0 - 1.0; + float brightness = 0.3 + 0.7 * pow(hash11(fi * 3.1 + 47.0), 2.0); + float twinkle = 0.7 + 0.3 * sin(iTime * (1.0 + hash11(fi * 5.7) * 3.0) + fi * 6.28); + brightness *= twinkle; + float dist = length(uv - starPos); + // Sharp Gaussian dot: peak = brightness (0.3~1.0), very small radius, no accumulation washout + float glow = brightness * exp(-dist * dist * 8000.0); + // Add soft halo to make stars more visible + glow += brightness * 0.0008 / (dist * dist + 0.003); + float temp = hash11(fi * 11.3 + 23.0); + vec3 starCol = mix(vec3(0.6, 0.7, 1.0), vec3(1.0, 0.9, 0.7), temp); + col += starCol * glow; + } + return col; +} +``` + +### Incorrect Pattern (Do Not Do This) +```glsl +// WRONG: cannot write this in standalone HTML +void mainImage(out vec4 fragColor, in vec2 fragCoord) { ... } +void main() { + mainImage(fragColor, fragCoord); // compilation error! +} +``` + +## ShaderToy vs Standalone HTML Code Templates + +The following code examples fall into two categories; be sure to use the correct template: + +### Standalone HTML Template (complete example provided above) + +### ShaderToy Template (for the ShaderToy website) + +## Use Cases +- **Stateless particle effects**: fireworks, starfields, campfire/flames (flying embers), orbiting light points, and other decorative effects that don't need inter-frame memory +- **Stateful physics particles**: flocking/boids, raindrops, cloth, fluids, and other simulations requiring persistent position and velocity +- **Motion blur and trails**: particle trajectories needing afterglow or halo effects +- **Large-scale particle management**: real-time rendering and interaction with hundreds to thousands of particles +- **Weather/atmospheric effects**: sandstorms, blizzards, volcanic ash, and other vortex-driven particle systems +- **Magic/geometric arrays**: magic dust, spiraling ascending light points, magic circle rings, iridescent shimmering particles + +Core decision tree: Do particles need inter-frame memory? +- **No** → Single-pass stateless system (using loops + hash functions) +- **Yes** → Multi-pass stateful system (using Buffer for position/velocity storage) + +## Core Principles + +Particle systems manage collections of many independent entities, each with position, velocity, lifetime, and other attributes. + +**Stateless paradigm**: All attributes are recomputed each frame from particle ID and time, no Buffer needed. +``` +position_i = trajectory(id_i, time) + randomOffset(hash(id_i)) +lifetime_i = fract((time - spawnTime_i) / lifeDuration_i) +``` + +**Stateful paradigm**: Particle state is stored in Buffer texture pixels, each frame reading → computing → writing back. +``` +// Euler method +velocity += acceleration * dt +position += velocity * dt + +// Verlet integration (no explicit velocity, more stable) +newPos = 2 * currentPos - previousPos + acceleration * dt² +``` + +**Rendering**: Distance-based falloff `intensity = brightness / (dist² + epsilon)`, with multi-particle superposition creating metaball fusion effects. + +## Implementation Steps + +### Step 1: Hash Random Functions +```glsl +// 1D -> 1D hash, returns [0, 1) +float hash11(float p) { + return fract(sin(p * 127.1) * 43758.5453); +} + +// 1D -> 2D hash +vec2 hash12(float p) { + vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); +} + +// 3D -> 3D hash +vec3 hash33(vec3 p) { + p = fract(p * vec3(443.897, 397.297, 491.187)); + p += dot(p.zxy, p.yxz + 19.19); + return fract(vec3(p.x * p.y, p.z * p.x, p.y * p.z)) - 0.5; +} +``` + +### Step 2: Particle Lifecycle Management +```glsl +#define NUM_PARTICLES 100 +#define LIFETIME_MIN 1.0 +#define LIFETIME_MAX 3.0 +#define START_TIME 2.0 + +// Returns: x = normalized age [0,1], y = life cycle number +vec2 particleAge(int id, float time) { + float spawnTime = START_TIME * hash11(float(id) * 2.0); + float lifetime = mix(LIFETIME_MIN, LIFETIME_MAX, hash11(float(id) * 3.0 - 35.0)); + float age = mod(time - spawnTime, lifetime); + float run = floor((time - spawnTime) / lifetime); + return vec2(age / lifetime, run); +} +``` + +### Step 3: Stateless Particle Position Computation +```glsl +#define GRAVITY vec2(0.0, -4.5) +#define DRIFT_MAX vec2(0.28, 0.28) + +// Harmonic superposition for smooth main trajectory +float harmonics(vec3 freq, vec3 amp, vec3 phase, float t) { + float val = 0.0; + for (int h = 0; h < 3; h++) + val += amp[h] * cos(t * freq[h] * 6.2832 + phase[h] / 360.0 * 6.2832); + return (1.0 + val) / 2.0; +} + +vec2 particlePosition(int id, float time) { + vec2 ageInfo = particleAge(id, time); + float age = ageInfo.x; + float run = ageInfo.y; + + float slowTime = time * 0.1; + vec2 mainPos = vec2( + harmonics(vec3(0.4, 0.66, 0.78), vec3(0.8, 0.24, 0.18), vec3(0.0, 45.0, 55.0), slowTime), + harmonics(vec3(0.415, 0.61, 0.82), vec3(0.72, 0.28, 0.15), vec3(90.0, 120.0, 10.0), slowTime) + ); + + vec2 drift = DRIFT_MAX * (vec2(hash11(float(id) * 3.0 + run * 4.0), + hash11(float(id) * 7.0 - run * 2.5)) - 0.5) * age; + vec2 grav = GRAVITY * age * age * 0.5; + + return mainPos + drift + grav; +} +``` + +### Step 4: Buffer-Stored Particle State (Stateful System) +```glsl +// === Buffer A: Particle Physics Update === +// IMPORTANT: Multi-pass system warning: each fragment shader is compiled independently, helper functions must be redefined in each shader! +#define NUM_PARTICLES 40 +#define MAX_VEL 0.5 +#define MAX_ACC 3.0 +#define RESIST 0.2 +#define DT 0.03 + +// Helper functions that must be defined in the Buffer A shader +float hash11(float p) { + return fract(sin(p * 127.1) * 43758.5453); +} + +vec2 hash12(float p) { + vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); +} + +vec4 loadParticle(float i) { + return texelFetch(iChannel0, ivec2(i, 0), 0); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + if (fragCoord.y > 0.5 || fragCoord.x > float(NUM_PARTICLES)) discard; + + float id = floor(fragCoord.x); + vec2 res = iResolution.xy / iResolution.y; + + if (iFrame < 5) { + vec2 rng = hash12(id); + fragColor = vec4(0.1 + 0.8 * rng * res, 0.0, 0.0); + return; + } + + vec4 particle = loadParticle(id); // xy = pos, zw = vel + vec2 pos = particle.xy; + vec2 vel = particle.zw; + + vec2 force = vec2(0.0); + force += 0.8 * (1.0 / abs(pos) - 1.0 / abs(res - pos)); // boundary repulsion + for (float i = 0.0; i < float(NUM_PARTICLES); i++) { // inter-particle interaction + if (i == id) continue; + vec4 other = loadParticle(i); + vec2 w = pos - other.xy; + float d = length(w); + if (d > 0.0) + force -= w * (6.3 + log(d * d * 0.02)) / exp(d * d * 2.4) / d; + } + force -= vel * RESIST / DT; // friction + + vec2 acc = force; + float a = length(acc); + acc *= a > MAX_ACC ? MAX_ACC / a : 1.0; + vel += acc * DT; + float v = length(vel); + vel *= v > MAX_VEL ? MAX_VEL / v : 1.0; + pos += vel * DT; + + fragColor = vec4(pos, vel); +} +``` + +### Step 5: Particle Rendering — Metaball Style +// IMPORTANT: Multi-pass system warning: Image shader must define all the following helper functions (compiled independently)! +```glsl +#define BRIGHTNESS 0.002 +#define COLOR_START vec3(0.0, 0.64, 0.2) +#define COLOR_END vec3(0.06, 0.35, 0.85) + +// Helper functions that must be defined in the Image shader +float hash11(float p) { + return fract(sin(p * 127.1) * 43758.5453); +} + +vec2 hash12(float p) { + vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); +} + +vec4 loadParticle(float i) { + return texelFetch(iChannel0, ivec2(i, 0), 0); +} + +// HSV to RGB (correct implementation) +vec3 hsv2rgb(vec3 c) { + vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); + vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); + return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); +} + +vec3 renderParticles(vec2 uv) { + vec3 col = vec3(0.0); + float totalWeight = 0.0; + for (int i = 0; i < NUM_PARTICLES; i++) { + vec4 particle = loadParticle(float(i)); + vec2 p = uv - particle.xy; + float mb = BRIGHTNESS / dot(p, p); + totalWeight += mb; + float ratio = length(particle.zw) / MAX_VEL; + vec3 pcol = mix(COLOR_START, COLOR_END, ratio); + col = mix(col, pcol, mb / totalWeight); + } + totalWeight /= float(NUM_PARTICLES); + col = normalize(col) * clamp(totalWeight, 0.0, 0.4); + return col; +} +``` + +### Step 6: Frame Feedback Motion Blur +```glsl +// IMPORTANT: Ping-pong brightness budget (most common washout cause!): +// Steady-state brightness = singleFrameContribution / (1 - TRAIL_DECAY) +// decay=0.88 → 8.3x amplification, decay=0.95 → 20x amplification +// Budget formula: N_particles x (numerator/epsilon) x 1/(1-decay) < 10.0 +// +// Safe parameter lookup table (decay=0.88, 8.3x amplification): +// 20 particles → single particle peak < 0.06 (numerator=0.002, epsilon=0.03) +// 50 particles → single particle peak < 0.024 (numerator=0.001, epsilon=0.04) +// 100 particles → single particle peak < 0.012 (numerator=0.0005, epsilon=0.04) +// +// Safe parameter lookup table (decay=0.92, 12.5x amplification): +// 20 particles → single particle peak < 0.04 (numerator=0.001, epsilon=0.03) +// 50 particles → single particle peak < 0.016 (numerator=0.0005, epsilon=0.03) +// 100 particles → single particle peak < 0.008 (numerator=0.0003, epsilon=0.04) +#define TRAIL_DECAY 0.88 + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec3 prev = texture(iChannel0, uv).rgb * TRAIL_DECAY; + vec3 current = renderParticles(fragCoord / iResolution.y); + fragColor = vec4(prev + current, 1.0); +} +``` + +### Step 7: HSV Coloring & Star Glare Effect +```glsl +// HSV to RGB (correct implementation) +vec3 hsv2rgb(vec3 c) { + vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); + vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); + return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); +} + +// Star glare: thin rays in horizontal/vertical/diagonal directions +float starGlare(vec2 relPos, float intensity) { + vec2 stretch = vec2(9.0, 0.32); + float dh = length(relPos * stretch); + float dv = length(relPos * stretch.yx); + vec2 diagPos = 0.707 * vec2(dot(relPos, vec2(1, 1)), dot(relPos, vec2(1, -1))); + float dd1 = length(diagPos * vec2(13.0, 0.61)); + float dd2 = length(diagPos * vec2(0.61, 13.0)); + float glare = 0.25 / (dh * 3.0 + 0.01) + + 0.25 / (dv * 3.0 + 0.01) + + 0.19 / (dd1 * 3.0 + 0.01) + + 0.19 / (dd2 * 3.0 + 0.01); + return glare * intensity; +} +``` + +## Complete Code Template + +Single-pass stateless particle system, runs directly in ShaderToy's Image tab: + +```glsl +// === Particle System — Stateless Single-Pass Template === + +#define NUM_PARTICLES 80 +#define LIFETIME_MIN 1.0 +#define LIFETIME_MAX 3.5 +#define START_TIME 2.5 +#define BRIGHTNESS 0.00004 +#define GRAVITY vec2(0.0, -2.0) +#define DRIFT_SPEED 0.2 +#define HUE_SHIFT 0.035 +#define TRAIL_DECAY 0.92 +#define STAR_ENABLED 1 + +#define PI 3.14159265 +#define TAU 6.28318530 + +float hash11(float p) { + return fract(sin(p * 127.1) * 43758.5453); +} + +vec3 hsv2rgb(vec3 c) { + vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); + vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); + return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); +} + +float harmonics3(vec3 freq, vec3 amp, vec3 phase, float t) { + float val = 0.0; + for (int h = 0; h < 3; h++) + val += amp[h] * cos(t * freq[h] * TAU + phase[h] / 360.0 * TAU); + return (1.0 + val) * 0.5; +} + +vec3 getLifecycle(int id, float time) { + float spawn = START_TIME * hash11(float(id) * 2.0); + float life = mix(LIFETIME_MIN, LIFETIME_MAX, hash11(float(id) * 3.0 - 35.0)); + float age = mod(time - spawn, life); + float run = floor((time - spawn) / life); + return vec3(age / life, run, spawn); +} + +vec2 getPosition(int id, float time) { + vec3 lc = getLifecycle(id, time); + float age = lc.x; + float run = lc.y; + + float tfact = mix(6.0, 20.0, hash11(float(id) * 2.0 + 94.0 + run * 1.5)); + float pt = (run * lc.x * mix(LIFETIME_MIN, LIFETIME_MAX, hash11(float(id)*3.0-35.0)) + lc.z) * (-1.0/tfact + 1.0) + time / tfact; + + vec2 mainPos = vec2( + harmonics3(vec3(0.4, 0.66, 0.78), vec3(0.8, 0.24, 0.18), vec3(0.0, 45.0, 55.0), pt), + harmonics3(vec3(0.415, 0.61, 0.82), vec3(0.72, 0.28, 0.15), vec3(90.0, 120.0, 10.0), pt) + ) + vec2(0.35, 0.15); + + vec2 drift = DRIFT_SPEED * (vec2( + hash11(float(id) * 3.0 - 23.0 + run * 4.0), + hash11(float(id) * 7.0 + 632.0 - run * 2.5) + ) - 0.5) * age; + + vec2 grav = GRAVITY * age * age * 0.004; + + return (mainPos + drift + grav) * vec2(0.6, 0.45); +} + +float starGlare(vec2 rel) { + #if STAR_ENABLED == 0 + return 0.0; + #endif + vec2 stretchHV = vec2(9.0, 0.32); + float dh = length(rel * stretchHV); + float dv = length(rel * stretchHV.yx); + vec2 dRel = 0.707 * vec2(dot(rel, vec2(1, 1)), dot(rel, vec2(1, -1))); + vec2 stretchDiag = vec2(13.0, 0.61); + float dd1 = length(dRel * stretchDiag); + float dd2 = length(dRel * stretchDiag.yx); + return 0.25 / (dh * 3.0 + 0.01) + 0.25 / (dv * 3.0 + 0.01) + + 0.19 / (dd1 * 3.0 + 0.01) + 0.19 / (dd2 * 3.0 + 0.01); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord.xy / iResolution.xx; + float time = iTime * 0.75; + + vec3 col = vec3(0.0); + + for (int i = 1; i < NUM_PARTICLES; i++) { + vec3 lc = getLifecycle(i, time); + float age = lc.x; + float run = lc.y; + + vec2 ppos = getPosition(i, time); + vec2 rel = uv - ppos; + float dist = length(rel); + + float baseInt = mix(0.1, 3.2, hash11(run * 4.0 + float(i) - 55.0)); + float glow = 1.0 / (dist * 3.0 + 0.015); + + float star = starGlare(rel); + float intensity = baseInt * pow(glow + star, 2.3) / 40000.0; + + intensity *= (1.0 - age); + intensity *= smoothstep(0.0, 0.15, age); + float sparkFreq = mix(2.5, 6.0, hash11(float(i) * 5.0 + 72.0 - run * 1.8)); + intensity *= 0.5 * sin(sparkFreq * TAU * time) + 1.0; + + float hue = mix(-0.13, 0.13, hash11(float(i) + 124.0 + run * 1.5)) + HUE_SHIFT * time; + float sat = mix(0.5, 0.9, hash11(float(i) * 6.0 + 44.0 + run * 3.3)) * 0.45 / max(intensity, 0.001); + col += hsv2rgb(vec3(hue, clamp(sat, 0.0, 1.0), intensity)); + } + + col = pow(max(col, 0.0), vec3(1.0 / 2.2)); + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Variant 1: Metaball Polar Coordinate Particles +```glsl +float d = fract(time * 0.51 + 48934.4238 * sin(float(i) * 692.7398)); +float angle = TAU * float(i) / float(NUM_PARTICLES); +vec2 particlePos = d * vec2(cos(angle), sin(angle)) * 4.0; + +vec2 p = uv - particlePos; +float mb = 0.84 / dot(p, p); +col = mix(col, mix(startColor, endColor, d), mb / totalSum); +``` + +### Variant 2: Buffer Storage + Boids Flocking Behavior +```glsl +vec2 sumForce = vec2(0.0); +for (float j = 0.0; j < NUM_PARTICLES; j++) { + if (j == id) continue; + vec4 other = texelFetch(iChannel0, ivec2(j, 0), 0); + vec2 w = pos - other.xy; + float d = length(w); + sumForce -= w * (6.3 + log(d * d * 0.02)) / exp(d * d * 2.4) / d; +} +sumForce -= vel * 0.2 / dt; +``` + +### Variant 3: Verlet Integration Cloth Simulation +```glsl +vec2 newPos = 2.0 * particle.xy - particle.zw + vec2(0.0, -0.6) * dt * dt; +particle.zw = particle.xy; +particle.xy = newPos; + +vec4 neighbor = texelFetch(iChannel0, neighborId, 0); +vec2 delta = neighbor.xy - particle.xy; +float dist = length(delta); +float restLength = 0.1; +particle.xy += 0.1 * (dist - restLength) * (delta / dist); +``` + +### Variant 4: 3D Particles + Ray Rendering +```glsl +vec3 ro = vec3(0.0, 0.0, 2.5); +vec3 rd = normalize(vec3(uv, -0.5)); +for (int i = 0; i < numParticles; i++) { + vec3 pos = texture(iChannel0, vec2(i, 100.0) * w).rgb; + float d = dot(cross(pos - ro, rd), cross(pos - ro, rd)); + d *= 1000.0; + float glow = 0.14 / (pow(d, 1.1) + 0.03); + col += glow * particleColor; +} +``` + +### Variant 5: Raindrop Particles (3D Scene Integration) +```glsl +float speedScale = 0.0015 * (0.1 + 1.9 * sin(PI * 0.5 * pow(age / lifetime, 2.0))); +particle.x += (windShieldOffset.x + windIntensity * dot(rayRight, windDir)) * fallSpeed * speedScale * dt; +particle.y += (windShieldOffset.y + windIntensity * dot(rayUp, windDir)) * fallSpeed * speedScale * dt; +particle.xy += 0.001 * (randVec2(particle.xy + iTime) - 0.5) * jitterSpeed * dt; +if (particle.z > particle.a) { + particle.xy = vec2(rand(seedX), rand(seedY)) * iResolution.xy; + particle.a = lifetimeMin + rand(pid) * (lifetimeMax - lifetimeMin); + particle.z = 0.0; +} +``` + +### Variant 6: Vortex/Storm Particle System (Sandstorm, Blizzard, Whirlwind, etc.) + +Uses stateless single pass. Key: spiral trajectory + high-visibility particles + vortex eye dark zone + separated background fog layer. + +```glsl +// IMPORTANT: Particle color must be 2-3x brighter than background to be visible (sand-colored particles on sand-colored background easily disappear) +// IMPORTANT: Brightness budget: 150 particles x peak(0.005/0.003=1.67) x fade(avg~0.3) ≈ 75, overexposed! +// Must increase epsilon or decrease numerator. Safe values: numerator=0.002, epsilon=0.008 → peak=0.25, total=11 → OK after Reinhard +#define NUM_DUST 150 +#define VORTEX_CENTER vec2(0.0) + +void main() { + vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y; + float t = iTime; + + vec3 bg = mix(vec3(0.25, 0.18, 0.08), vec3(0.4, 0.28, 0.12), gl_FragCoord.y / iResolution.y); + + vec3 col = vec3(0.0); + for (int i = 0; i < NUM_DUST; i++) { + float fi = float(i); + float life = mix(2.0, 5.0, hash11(fi * 3.7)); + float age = mod(t - hash11(fi * 2.0) * life, life); + float norm = age / life; + + float initAngle = hash11(fi * 7.3) * 6.2832; + float initR = 0.05 + hash11(fi * 11.0) * 0.5; + float angularSpeed = 2.0 / (0.3 + initR); + float angle = initAngle + norm * angularSpeed; + float radius = initR + norm * 0.15; + + vec2 pos = VORTEX_CENTER + vec2(cos(angle), sin(angle)) * radius; + + vec2 rel = uv - pos; + float dist = length(rel); + + float fade = smoothstep(0.0, 0.1, norm) * smoothstep(1.0, 0.5, norm); + // Safe brightness: peak = 0.002/0.008 = 0.25, x 150 x avg_fade(0.3) ≈ 11 → Reinhard OK + float glow = 0.002 / (dist * dist + 0.008) * fade; + + // Particles need to be noticeably brighter than background, use light sand + white blend + vec3 dustColor = mix(vec3(1.0, 0.9, 0.6), vec3(1.0, 0.95, 0.85), hash11(fi * 5.0)); + col += dustColor * glow; + } + + float eyeDist = length(uv - VORTEX_CENTER); + float eye = smoothstep(0.06, 0.15, eyeDist); + + vec3 final = bg + col * eye; + final = final / (1.0 + final); + fragColor = vec4(final, 1.0); +} +``` + +### Variant 7: Meteor/Trail Line Rendering (Single-Pass Stateless) + +Meteors, magic projectiles, etc. need elongated glow (stretched luminous lines). **Do not use `1/(distPerp² + tiny_epsilon)` for lines** — too-small epsilon makes line centers extremely bright and washed out. Use `exp(-dist)` or `smoothstep` for safe line glow. + +**IMPORTANT: Common meteor failures**: (1) Star background too dark to see — must call `starField()` above and ensure stars use Gaussian dots `exp(-dist²*k)` for rendering (2) Meteor trail too faint — `core` multiplier should be at least 0.15, each step after dividing by `NUM_TRAIL_STEPS` still needs >= 0.005 contribution + +```glsl +#define NUM_METEORS 6 +#define NUM_TRAIL_STEPS 20 + +void main() { + vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y; + float t = iTime; + + // Deep blue night sky background + must call starField to draw stars + vec3 col = vec3(0.005, 0.005, 0.02); + col += starField(uv); + + for (int m = 0; m < NUM_METEORS; m++) { + float fm = float(m); + float cycleTime = mix(3.0, 7.0, hash11(fm * 17.3)); + float meteorTime = mod(t - hash11(fm * 23.7) * cycleTime, cycleTime); + float travelDuration = mix(0.5, 1.2, hash11(fm * 31.1)); + + if (meteorTime > travelDuration + 0.3) continue; + + float angle = mix(-0.4, -1.3, hash11(fm * 41.3)); + vec2 dir = normalize(vec2(cos(angle), sin(angle))); + vec2 startPos = vec2( + mix(-0.3, 0.8, hash11(fm * 53.7)), + mix(0.2, 0.7, hash11(fm * 61.1)) + ); + float speed = mix(1.0, 2.0, hash11(fm * 71.3)); + float headT = clamp(meteorTime / travelDuration, 0.0, 1.0); + vec2 headPos = startPos + dir * speed * headT; + + float headFade = smoothstep(0.0, 0.1, meteorTime) + * smoothstep(travelDuration + 0.3, travelDuration, meteorTime); + + float trailLen = mix(0.15, 0.35, hash11(fm * 83.7)); + + for (int s = 0; s < NUM_TRAIL_STEPS; s++) { + float sf = float(s) / float(NUM_TRAIL_STEPS); + vec2 samplePos = headPos - dir * trailLen * sf; + vec2 rel = uv - samplePos; + + float distPerp = abs(dot(rel, vec2(-dir.y, dir.x))); + + // Line width: narrow at head, wide at tail + float width = mix(0.003, 0.015, sf); + // core multiplier 0.15 ensures trail is visible even under SwiftShader + float core = exp(-distPerp / width) * 0.15; + + float trailFade = (1.0 - sf) * (1.0 - sf); + float intensity = core * trailFade * headFade / float(NUM_TRAIL_STEPS); + + float hue = mix(0.05, 0.12, sf); + vec3 meteorCol = hsv2rgb(vec3(hue, mix(0.1, 0.4, sf), 1.0)); + col += meteorCol * intensity; + } + + // Meteor head: bright point + float headDist = length(uv - headPos); + float headGlow = headFade * 0.005 / (headDist * headDist + 0.0008); + col += vec3(1.0, 0.95, 0.85) * headGlow; + } + + col = col / (1.0 + col); + col = pow(col, vec3(0.95)); + fragColor = vec4(col, 1.0); +} +``` + +### Variant 8: Fountain/Upward Jet Particle System (Single-Pass Stateless) + +Water/sparks jetting upward from a point, parabolic descent. Key: **Particles must be sharp, individually visible points** (small epsilon), not just a diffuse glow blob. Must include: (1) main water column particles (upward jet + parabola) (2) splash particles (spread sideways after hitting water) (3) water surface/pool visuals. + +**IMPORTANT: Most common fountain failure**: Only produces blurry glow without visible individual water droplet trajectories! Must use small epsilon (<=0.002) so each particle is clearly visible as an individual light point. Numerator must also be proportionally reduced to control total brightness. + +```glsl +#define NUM_WATER 60 +#define NUM_SPLASH 40 +#define FOUNTAIN_BASE vec2(0.0, -0.3) +#define WATER_LEVEL -0.3 + +void main() { + vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y; + float t = iTime; + + // Dark background + vec3 col = vec3(0.01, 0.02, 0.06); + + // --- Water pool/surface --- + float waterDist = abs(uv.y - WATER_LEVEL); + float waterLine = smoothstep(0.01, 0.0, waterDist) * 0.3; + float waterBody = smoothstep(WATER_LEVEL, WATER_LEVEL - 0.15, uv.y); + col += vec3(0.02, 0.06, 0.12) * waterBody; + col += vec3(0.3, 0.5, 0.7) * waterLine; + + // --- Main water column particles: upward jet + parabola --- + for (int i = 0; i < NUM_WATER; i++) { + float fi = float(i); + float lifetime = mix(1.0, 2.0, hash11(fi * 3.7)); + float age = mod(t - hash11(fi * 2.3) * lifetime, lifetime); + float norm = age / lifetime; + + float spreadAngle = (hash11(fi * 7.3) - 0.5) * 0.6; + float speed = mix(0.9, 1.6, hash11(fi * 11.0)); + vec2 vel0 = vec2(sin(spreadAngle), cos(spreadAngle)) * speed; + + vec2 pos = FOUNTAIN_BASE + vel0 * age + vec2(0.0, -1.8) * age * age; + + if (pos.y < WATER_LEVEL - 0.02) continue; + + vec2 rel = uv - pos; + float dist = length(rel); + + float fade = smoothstep(0.0, 0.05, norm) * smoothstep(1.0, 0.6, norm); + // Sharp light point: small epsilon makes each particle clearly visible as an individual dot + // peak = 0.004/0.0015 = 2.67, x 60 x avg_fade(0.25) ≈ 40 → Reinhard OK + float glow = 0.004 / (dist * dist + 0.0015) * fade; + + vec3 waterCol = mix(vec3(0.5, 0.8, 1.0), vec3(0.9, 0.97, 1.0), hash11(fi * 5.0)); + col += waterCol * glow; + } + + // --- Splash particles: spread sideways at water surface --- + for (int i = 0; i < NUM_SPLASH; i++) { + float fi = float(i) + 200.0; + float lifetime = mix(0.3, 0.8, hash11(fi * 3.7)); + float age = mod(t - hash11(fi * 2.3) * lifetime, lifetime); + float norm = age / lifetime; + + float xOffset = (hash11(fi * 7.3) - 0.5) * 0.5; + vec2 splashBase = vec2(xOffset, WATER_LEVEL); + float splashAngle = (hash11(fi * 11.0) - 0.5) * 2.5; + float splashSpeed = mix(0.2, 0.5, hash11(fi * 13.0)); + vec2 splashVel = vec2(sin(splashAngle), abs(cos(splashAngle))) * splashSpeed; + + vec2 pos = splashBase + splashVel * age + vec2(0.0, -2.0) * age * age; + if (pos.y < WATER_LEVEL - 0.01) continue; + + vec2 rel = uv - pos; + float dist = length(rel); + + float fade = smoothstep(0.0, 0.05, norm) * smoothstep(1.0, 0.3, norm); + float glow = 0.002 / (dist * dist + 0.001) * fade; + + col += vec3(0.7, 0.85, 1.0) * glow; + } + + col = col / (1.0 + col); + fragColor = vec4(col, 1.0); +} +``` + +### Variant 9: Campfire/Flame Particle System (Single-Pass Stateless) + +Flame effects must include **two layers**: (1) smooth flame body at the base (noise-driven cone gradient) (2) many **discrete ember/spark particles** above, drifting upward and gradually extinguishing. Using only a smooth gradient will be judged as "no particle system." + +**IMPORTANT: Most common flame failure**: Only draws a smooth gradient without discrete particles! Must have NUM_SPARKS individual point-like particles drifting out from the flame top. + +```glsl +#define NUM_SPARKS 60 +#define FIRE_BASE vec2(0.0, -0.35) + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float a = hash11(dot(i, vec2(127.1, 311.7))); + float b = hash11(dot(i + vec2(1.0, 0.0), vec2(127.1, 311.7))); + float c = hash11(dot(i + vec2(0.0, 1.0), vec2(127.1, 311.7))); + float d = hash11(dot(i + vec2(1.0, 1.0), vec2(127.1, 311.7))); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +void main() { + vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y; + float t = iTime; + vec3 col = vec3(0.02, 0.01, 0.01); + + // --- Layer 1: flame body (smooth noise cone) --- + vec2 fireUV = uv - FIRE_BASE; + float fireH = clamp(fireUV.y / 0.5, 0.0, 1.0); + float width = mix(0.15, 0.01, fireH); + float n = noise(vec2(fireUV.x * 6.0, fireUV.y * 4.0 - t * 3.0)); + float flameShape = smoothstep(width, 0.0, abs(fireUV.x + (n - 0.5) * 0.08)) + * smoothstep(-0.02, 0.05, fireUV.y) + * smoothstep(0.55, 0.0, fireUV.y); + vec3 innerCol = vec3(1.0, 0.95, 0.7); + vec3 outerCol = vec3(1.0, 0.35, 0.05); + vec3 flameCol = mix(outerCol, innerCol, smoothstep(0.3, 0.8, flameShape)); + col += flameCol * flameShape * 1.5; + + // --- Layer 2: discrete ember particles (required!) --- + for (int i = 0; i < NUM_SPARKS; i++) { + float fi = float(i); + float lifetime = mix(0.8, 2.0, hash11(fi * 3.7)); + float age = mod(t - hash11(fi * 2.3) * lifetime, lifetime); + float norm = age / lifetime; + + float xSpread = (hash11(fi * 7.3) - 0.5) * 0.2; + float riseSpeed = mix(0.3, 0.7, hash11(fi * 11.0)); + float wobble = sin(t * 3.0 + fi * 2.7) * 0.03 * norm; + vec2 sparkPos = FIRE_BASE + vec2(0.0, 0.25) + + vec2(xSpread + wobble, riseSpeed * age); + + vec2 rel = uv - sparkPos; + float dist = length(rel); + + float fade = smoothstep(0.0, 0.1, norm) * smoothstep(1.0, 0.3, norm); + // peak = 0.003/0.0008 = 3.75, x 60 x avg_fade(0.2) ≈ 45 → Reinhard OK + float glow = 0.003 / (dist * dist + 0.0008) * fade; + + float hue = mix(0.03, 0.12, norm); + vec3 sparkCol = hsv2rgb(vec3(hue, mix(0.9, 0.3, norm), 1.0)); + col += sparkCol * glow; + } + + col = col / (1.0 + col); + col = pow(col, vec3(0.95)); + fragColor = vec4(col, 1.0); +} +``` + +### Variant 10: Spiral Array/Magic Particle System (Single-Pass Stateless) + +Magic effects, spiral ascent, magic circles, etc. require particles arranged in **geometric arrays** with **iridescent shimmer**. Key: particles must be individually visible glowing points (not a blurry glow blob), and the spiral structure must be clearly discernible. + +**IMPORTANT: Most common magic failure**: Only produces a blob of blurry light (diffuse glow blob) without visible individual particles or geometric structure! Ensure each particle is an independently visible small light point, and the overall arrangement forms spiral/ring/other geometric shapes. Reduce epsilon to make each particle sharper (small light dot) rather than a large blurry halo. + +```glsl +#define NUM_SPIRAL 80 +#define NUM_RING 40 +#define WAND_TIP vec2(0.0, -0.15) + +void main() { + vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y; + float t = iTime; + vec3 col = vec3(0.01, 0.005, 0.02); + + // --- Layer 1: spiral ascending particles (emanating from emission point) --- + for (int i = 0; i < NUM_SPIRAL; i++) { + float fi = float(i); + float lifetime = mix(2.0, 4.0, hash11(fi * 3.7)); + float age = mod(t - hash11(fi * 2.3) * lifetime, lifetime); + float norm = age / lifetime; + + // Spiral trajectory: angle increases with time and height + float baseAngle = fi / float(NUM_SPIRAL) * 6.2832 * 3.0; + float spiralAngle = baseAngle + norm * 8.0 + t * 1.5; + float radius = 0.05 + norm * 0.25; + float height = norm * 0.7; + + vec2 pos = WAND_TIP + vec2(cos(spiralAngle) * radius, height); + + vec2 rel = uv - pos; + float dist = length(rel); + + float fade = smoothstep(0.0, 0.08, norm) * smoothstep(1.0, 0.4, norm); + // Sharp small light point: small epsilon makes particles clearly visible as individual dots + // peak = 0.004/0.0006 = 6.67, x 80 x avg_fade(0.25) ≈ 133 → Reinhard OK + float glow = 0.004 / (dist * dist + 0.0006) * fade; + + // Iridescent effect: hue varies with particle ID + time, producing rainbow shimmer + float hue = fract(fi / float(NUM_SPIRAL) + t * 0.3 + norm * 0.5); + float shimmer = 0.7 + 0.3 * sin(t * 8.0 + fi * 3.7); + vec3 pCol = hsv2rgb(vec3(hue, 0.7, 1.0)) * shimmer; + col += pCol * glow; + } + + // --- Layer 2: magic circle ring (horizontally rotating light point ring) --- + float ringY = WAND_TIP.y + 0.45; + float ringRadius = 0.2 + 0.03 * sin(t * 2.0); + for (int i = 0; i < NUM_RING; i++) { + float fi = float(i); + float angle = fi / float(NUM_RING) * 6.2832 + t * 2.0; + // Simulated perspective: ellipse (cos full width, sin compressed) + vec2 ringPos = vec2(cos(angle) * ringRadius, ringY + sin(angle) * ringRadius * 0.3); + + vec2 rel = uv - ringPos; + float dist = length(rel); + + float pulse = 0.6 + 0.4 * sin(t * 5.0 + fi * 1.5); + // peak = 0.003/0.0004 = 7.5, x 40 x avg_pulse(0.6) ≈ 180 → Reinhard OK + float glow = 0.003 / (dist * dist + 0.0004) * pulse; + + float hue = fract(fi / float(NUM_RING) + t * 0.5); + vec3 rCol = hsv2rgb(vec3(hue, 0.6, 1.0)); + col += rCol * glow; + } + + col = col / (1.0 + col); + col = pow(col, vec3(0.9)); + fragColor = vec4(col, 1.0); +} +``` + +## Performance & Composition + +**Performance**: +- Particle count is the biggest performance lever; use early exit `if (dist > threshold) continue;` for optimization +- Frame feedback trails (`prev * 0.95 + current`) can achieve high visual density with fewer particles +- N-body O(N²) interaction: reduce to O(1) neighbor queries using spatial grid partitioning or Voronoi tracking +- High-speed particles use sub-frame stepping to eliminate trajectory gaps +- Velocity/acceleration need clamp to prevent numerical explosion; Verlet is more stable than Euler +- + +**Composition**: +- **Raymarching**: sample particle density during march steps, or particles in separate Buffer then composited +- **Noise / Flow Field**: use noise gradients to drive particle velocity, producing organic flow effects +- **Post-Processing**: Bloom (Gaussian blur overlay), chromatic aberration, Reinhard tone mapping +- **SDF shapes**: rotate local coordinates based on velocity direction to render fish/droplet specific shapes +- **Voronoi acceleration**: large-scale particles use Voronoi tracking, reducing rendering and physics queries from O(N) to O(1) + +## Further Reading + +Full step-by-step tutorial, mathematical derivations, and advanced usage in [reference](../reference/particle-system.md) diff --git a/skills/shader-dev/techniques/path-tracing-gi.md b/skills/shader-dev/techniques/path-tracing-gi.md new file mode 100644 index 0000000..be8b563 --- /dev/null +++ b/skills/shader-dev/techniques/path-tracing-gi.md @@ -0,0 +1,623 @@ +Path tracing requires multi-pass rendering: Buffer A traces and accumulates samples each frame (iChannel0=self), Image Pass reads accumulated data and applies tone mapping for display. Below is the JS skeleton for standalone HTML: + +### Standalone HTML Multi-Pass Template (Ping-Pong Accumulation) + +```html + + + + + Path Tracer + + + + + + + +``` + +# Path Tracing & Global Illumination + +## Use Cases +- Physically accurate global illumination: indirect lighting, color bleeding, caustics +- Complex light transport with reflection, refraction, and diffuse interreflection +- Progressive high-quality rendering with multi-frame accumulation in ShaderToy +- Scenes requiring precise light interactions such as Cornell Box and glassware + +## Core Principles + +Path tracing solves the rendering equation via Monte Carlo methods. For each pixel, a ray is cast from the camera and bounced through the scene; at each bounce: intersect -> shade -> sample next direction -> accumulate contribution. + +Core formulas: +- **Rendering equation**: $L_o = L_e + \int f_r \cdot L_i \cdot \cos\theta \, d\omega$ +- **MC estimate**: $L \approx \frac{1}{N} \sum \frac{f_r \cdot L_i \cdot \cos\theta}{p(\omega)}$ +- **Schlick Fresnel**: $F = F_0 + (1 - F_0)(1 - \cos\theta)^5$ +- **Cosine-weighted PDF**: $p(\omega) = \cos\theta / \pi$ + +Use iterative loops instead of recursion: `acc` (accumulated radiance) and `throughput` (path attenuation) track path contributions. + +## Implementation Steps + +### Step 1: PRNG +```glsl +// Integer hash (recommended, good quality) +int iSeed; +int irand() { iSeed = iSeed * 0x343fd + 0x269ec3; return (iSeed >> 16) & 32767; } +float frand() { return float(irand()) / 32767.0; } +void srand(ivec2 p, int frame) { + int n = frame; + n = (n << 13) ^ n; n = n * (n * n * 15731 + 789221) + 1376312589; + n += p.y; + n = (n << 13) ^ n; n = n * (n * n * 15731 + 789221) + 1376312589; + n += p.x; + n = (n << 13) ^ n; n = n * (n * n * 15731 + 789221) + 1376312589; + iSeed = n; +} + +// Alternative: sin-hash (simpler) +float seed; +float rand() { return fract(sin(seed++) * 43758.5453123); } +``` + +### Step 2: Ray-Scene Intersection +```glsl +// Analytic sphere intersection +struct Ray { vec3 o, d; }; +struct Sphere { float r; vec3 p, e, c; int refl; }; + +float iSphere(Sphere s, Ray r) { + vec3 op = s.p - r.o; + float b = dot(op, r.d); + float det = b * b - dot(op, op) + s.r * s.r; + if (det < 0.) return 0.; + det = sqrt(det); + float t = b - det; + if (t > 1e-3) return t; + t = b + det; + return t > 1e-3 ? t : 0.; +} + +// SDF ray marching (complex geometry) +float map(vec3 p) { /* return distance to nearest surface */ } +float raymarch(vec3 ro, vec3 rd, float tmax) { + float t = 0.01; + for (int i = 0; i < 256; i++) { + float h = map(ro + rd * t); + if (abs(h) < 0.0001 || t > tmax) break; + t += h; + } + return t; +} +vec3 calcNormal(vec3 p) { + vec2 e = vec2(0.0001, 0.); + return normalize(vec3( + map(p + e.xyy) - map(p - e.xyy), + map(p + e.yxy) - map(p - e.yxy), + map(p + e.yyx) - map(p - e.yyx))); +} +``` + +### Step 3: Cosine-Weighted Hemisphere Sampling +```glsl +// fizzer method (most concise) +vec3 cosineDirection(vec3 n) { + float u = frand(), v = frand(); + float a = 6.2831853 * v; + float b = 2.0 * u - 1.0; + vec3 dir = vec3(sqrt(1.0 - b * b) * vec2(cos(a), sin(a)), b); + return normalize(n + dir); +} + +// ONB construction method (more intuitive) +vec3 cosineDirectionONB(vec3 n) { + vec2 r = vec2(frand(), frand()); + vec3 u = normalize(cross(n, vec3(0., 1., 1.))); + vec3 v = cross(u, n); + float ra = sqrt(r.y); + return normalize(ra * cos(6.2831853 * r.x) * u + ra * sin(6.2831853 * r.x) * v + sqrt(1.0 - r.y) * n); +} +``` + +### Step 4: Materials and BRDF +```glsl +#define MAT_DIFF 0 +#define MAT_SPEC 1 +#define MAT_REFR 2 + +// Diffuse: throughput *= albedo; dir = cosineDirection(nl) +// Specular: throughput *= albedo; dir = reflect(rd, n) + +// Refraction (glass) +void handleDielectric(inout Ray r, vec3 n, vec3 x, float ior, vec3 albedo, inout vec3 mask) { + float a = dot(n, r.d), ddn = abs(a); + float nnt = mix(1.0 / ior, ior, float(a > 0.)); + float cos2t = 1. - nnt * nnt * (1. - ddn * ddn); + r = Ray(x, reflect(r.d, n)); + if (cos2t > 0.) { + vec3 tdir = normalize(r.d * nnt + sign(a) * n * (ddn * nnt + sqrt(cos2t))); + float R0 = (ior - 1.) * (ior - 1.) / ((ior + 1.) * (ior + 1.)); + float c = 1. - mix(ddn, dot(tdir, n), float(a > 0.)); + float Re = R0 + (1. - R0) * c * c * c * c * c; + float P = .25 + .5 * Re; + if (frand() < P) { mask *= Re / P; } + else { mask *= albedo * (1. - Re) / (1. - P); r = Ray(x, tdir); } + } +} +``` + +### Step 5: Direct Light Sampling (NEE) +```glsl +// Spherical light solid angle sampling +vec3 coneSample(vec3 d, float phi, float sina, float cosa) { + vec3 w = normalize(d); + vec3 u = normalize(cross(w.yzx, w)); + vec3 v = cross(w, u); + return (u * cos(phi) + v * sin(phi)) * sina + w * cosa; +} + +// Called at diffuse shading points: +vec3 l0 = lightPos - x; +float cos_a_max = sqrt(1. - clamp(lightR * lightR / dot(l0, l0), 0., 1.)); +float cosa = mix(cos_a_max, 1., frand()); +vec3 l = coneSample(l0, 6.2831853 * frand(), sqrt(1. - cosa * cosa), cosa); +// After shadow test passes: +float omega = 6.2831853 * (1. - cos_a_max); +vec3 directLight = lightEmission * clamp(dot(l, nl), 0., 1.) * omega / PI; +``` + +### Step 6: Path Tracing Main Loop +```glsl +#define MAX_BOUNCES 8 + +vec3 pathtrace(Ray r) { + vec3 acc = vec3(0.), throughput = vec3(1.); + for (int depth = 0; depth < MAX_BOUNCES; depth++) { + // 1. Intersect + float t; vec3 n, albedo, emission; int matType; + if (!intersectScene(r, t, n, albedo, emission, matType)) break; + vec3 x = r.o + r.d * t; + vec3 nl = dot(n, r.d) < 0. ? n : -n; + + // 2. Accumulate self-emission + acc += throughput * emission; + + // 3. Russian roulette (starting from bounce 3) + if (depth > 2) { + float p = max(throughput.r, max(throughput.g, throughput.b)); + if (frand() > p) break; + throughput /= p; + } + + // 4. Material branching + if (matType == MAT_DIFF) { + acc += throughput * directLighting(x, nl, albedo, ...); // NEE + throughput *= albedo; + r = Ray(x + nl * 1e-3, cosineDirection(nl)); + } else if (matType == MAT_SPEC) { + throughput *= albedo; + r = Ray(x + nl * 1e-3, reflect(r.d, n)); + } else { + handleDielectric(r, n, x, 1.5, albedo, throughput); + } + } + return acc; +} +``` + +### Step 7: Progressive Accumulation and Display +```glsl +// Buffer A: path tracing + accumulation +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + srand(ivec2(fragCoord), iFrame); + // ... camera setup, ray generation ... + vec3 color = pathtrace(ray); + vec4 prev = texelFetch(iChannel0, ivec2(fragCoord), 0); + if (iFrame == 0) prev = vec4(0.); + fragColor = prev + vec4(color, 1.0); +} + +// Image Pass: ACES tone mapping + Gamma +vec3 ACES(vec3 x) { + float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14; + return (x * (a * x + b)) / (x * (c * x + d) + e); +} +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec4 data = texelFetch(iChannel0, ivec2(fragCoord), 0); + vec3 col = data.rgb / max(data.a, 1.0); + col = ACES(col); + col = pow(clamp(col, 0., 1.), vec3(1.0 / 2.2)); + vec2 uv = fragCoord / iResolution.xy; + col *= 0.5 + 0.5 * pow(16.0 * uv.x * uv.y * (1.0 - uv.x) * (1.0 - uv.y), 0.1); + fragColor = vec4(col, 1.0); +} +``` + +## Complete Code Template + +ShaderToy dual pass: Buffer A (path tracing + accumulation, iChannel0=self), Image (display). + +**Buffer A:** +```glsl +#define PI 3.14159265359 +#define MAX_BOUNCES 6 +#define SAMPLES_PER_FRAME 2 +#define NUM_SPHERES 9 +#define IOR_GLASS 1.5 +#define ENABLE_NEE + +#define MAT_DIFF 0 +#define MAT_SPEC 1 +#define MAT_REFR 2 + +int iSeed; +int irand() { iSeed = iSeed * 0x343fd + 0x269ec3; return (iSeed >> 16) & 32767; } +float frand() { return float(irand()) / 32767.0; } +void srand(ivec2 p, int frame) { + int n = frame; + n = (n << 13) ^ n; n = n * (n * n * 15731 + 789221) + 1376312589; + n += p.y; + n = (n << 13) ^ n; n = n * (n * n * 15731 + 789221) + 1376312589; + n += p.x; + n = (n << 13) ^ n; n = n * (n * n * 15731 + 789221) + 1376312589; + iSeed = n; +} + +struct Ray { vec3 o, d; }; +struct Sphere { float r; vec3 p, e, c; int refl; }; + +Sphere spheres[NUM_SPHERES]; +void initScene() { + spheres[0] = Sphere(1e5, vec3(-1e5+1., 40.8, 81.6), vec3(0.), vec3(.75,.25,.25), MAT_DIFF); + spheres[1] = Sphere(1e5, vec3( 1e5+99., 40.8, 81.6), vec3(0.), vec3(.25,.25,.75), MAT_DIFF); + spheres[2] = Sphere(1e5, vec3(50., 40.8, -1e5), vec3(0.), vec3(.75), MAT_DIFF); + spheres[3] = Sphere(1e5, vec3(50., 40.8, 1e5+170.), vec3(0.), vec3(0.), MAT_DIFF); + spheres[4] = Sphere(1e5, vec3(50., -1e5, 81.6), vec3(0.), vec3(.75), MAT_DIFF); + spheres[5] = Sphere(1e5, vec3(50., 1e5+81.6, 81.6), vec3(0.), vec3(.75), MAT_DIFF); + spheres[6] = Sphere(16.5, vec3(27., 16.5, 47.), vec3(0.), vec3(1.), MAT_SPEC); + spheres[7] = Sphere(16.5, vec3(73., 16.5, 78.), vec3(0.), vec3(.7,1.,.9), MAT_REFR); + spheres[8] = Sphere(600., vec3(50., 681.33, 81.6), vec3(12.), vec3(0.), MAT_DIFF); +} + +float iSphere(Sphere s, Ray r) { + vec3 op = s.p - r.o; + float b = dot(op, r.d); + float det = b * b - dot(op, op) + s.r * s.r; + if (det < 0.) return 0.; + det = sqrt(det); + float t = b - det; + if (t > 1e-3) return t; + t = b + det; + return t > 1e-3 ? t : 0.; +} + +int intersect(Ray r, out float t, out Sphere s, int avoid) { + int id = -1; t = 1e5; + for (int i = 0; i < NUM_SPHERES; ++i) { + float d = iSphere(spheres[i], r); + if (i != avoid && d > 0. && d < t) { t = d; id = i; s = spheres[i]; } + } + return id; +} + +vec3 cosineDirection(vec3 n) { + float u = frand(), v = frand(); + float a = 6.2831853 * v; + float b = 2.0 * u - 1.0; + vec3 dir = vec3(sqrt(1.0 - b * b) * vec2(cos(a), sin(a)), b); + return normalize(n + dir); +} + +vec3 coneSample(vec3 d, float phi, float sina, float cosa) { + vec3 w = normalize(d); + vec3 u = normalize(cross(w.yzx, w)); + vec3 v = cross(w, u); + return (u * cos(phi) + v * sin(phi)) * sina + w * cosa; +} + +vec3 radiance(Ray r) { + vec3 acc = vec3(0.), mask = vec3(1.); + int id = -1; + for (int depth = 0; depth < MAX_BOUNCES; ++depth) { + float t; Sphere obj; + if ((id = intersect(r, t, obj, id)) < 0) break; + vec3 x = r.o + r.d * t; + vec3 n = normalize(x - obj.p); + vec3 nl = n * sign(-dot(n, r.d)); + + if (depth > 3) { + float p = max(obj.c.r, max(obj.c.g, obj.c.b)); + if (frand() > p) { acc += mask * obj.e; break; } + mask /= p; + } + + if (obj.refl == MAT_DIFF) { + vec3 d = cosineDirection(nl); + vec3 e = vec3(0.); + #ifdef ENABLE_NEE + { + Sphere ls = spheres[8]; + vec3 l0 = ls.p - x; + float cos_a_max = sqrt(1. - clamp(ls.r * ls.r / dot(l0, l0), 0., 1.)); + float cosa = mix(cos_a_max, 1., frand()); + vec3 l = coneSample(l0, 6.2831853 * frand(), sqrt(1. - cosa * cosa), cosa); + float st; Sphere dummy; + if (intersect(Ray(x, l), st, dummy, id) == 8) { + float omega = 6.2831853 * (1. - cos_a_max); + e = ls.e * clamp(dot(l, nl), 0., 1.) * omega / PI; + } + } + #endif + acc += mask * obj.e + mask * obj.c * e; + mask *= obj.c; + r = Ray(x + nl * 1e-3, d); + } else if (obj.refl == MAT_SPEC) { + acc += mask * obj.e; + mask *= obj.c; + r = Ray(x + nl * 1e-3, reflect(r.d, n)); + } else { + acc += mask * obj.e; + float a = dot(n, r.d), ddn = abs(a); + float nc = 1., nt = IOR_GLASS; + float nnt = mix(nc / nt, nt / nc, float(a > 0.)); + float cos2t = 1. - nnt * nnt * (1. - ddn * ddn); + r = Ray(x, reflect(r.d, n)); + if (cos2t > 0.) { + vec3 tdir = normalize(r.d * nnt + sign(a) * n * (ddn * nnt + sqrt(cos2t))); + float R0 = (nt - nc) * (nt - nc) / ((nt + nc) * (nt + nc)); + float c = 1. - mix(ddn, dot(tdir, n), float(a > 0.)); + float Re = R0 + (1. - R0) * c * c * c * c * c; + float P = .25 + .5 * Re; + if (frand() < P) { mask *= Re / P; } + else { mask *= obj.c * (1. - Re) / (1. - P); r = Ray(x, tdir); } + } + } + } + return acc; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + initScene(); + srand(ivec2(fragCoord), iFrame); + vec2 uv = 2. * fragCoord / iResolution.xy - 1.; + vec3 camPos = vec3(50., 40.8, 169.); + vec3 cz = normalize(vec3(50., 40., 81.6) - camPos); + vec3 cx = vec3(1., 0., 0.); + vec3 cy = normalize(cross(cx, cz)); + cx = cross(cz, cy); + vec3 color = vec3(0.); + for (int i = 0; i < SAMPLES_PER_FRAME; i++) { + vec2 jitter = vec2(frand(), frand()) - 0.5; + vec2 suv = uv + jitter * 2.0 / iResolution.xy; + float fov = 0.53135; + vec3 rd = normalize(fov * (iResolution.x / iResolution.y * suv.x * cx + suv.y * cy) + cz); + color += radiance(Ray(camPos, rd)); + } + vec4 prev = texelFetch(iChannel0, ivec2(fragCoord), 0); + if (iFrame == 0) prev = vec4(0.); + fragColor = prev + vec4(color, float(SAMPLES_PER_FRAME)); +} +``` + +**Image Pass** (iChannel0 = Buffer A): +```glsl +vec3 ACES(vec3 x) { + float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14; + return (x * (a * x + b)) / (x * (c * x + d) + e); +} +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec4 data = texelFetch(iChannel0, ivec2(fragCoord), 0); + vec3 col = data.rgb / max(data.a, 1.0); + col = ACES(col); + col = pow(clamp(col, 0., 1.), vec3(1.0 / 2.2)); + vec2 uv = fragCoord / iResolution.xy; + col *= 0.5 + 0.5 * pow(16.0 * uv.x * uv.y * (1.0 - uv.x) * (1.0 - uv.y), 0.1); + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### 1. SDF Scene Path Tracing +```glsl +float map(vec3 p) { + float d = p.y + 0.5; + d = min(d, length(p - vec3(0., 0.4, 0.)) - 0.4); + return d; +} +float intersectScene(vec3 ro, vec3 rd, float tmax) { + float t = 0.01; + for (int i = 0; i < 128; i++) { + float h = map(ro + rd * t); + if (h < 0.0001 || t > tmax) break; + t += h; + } + return t < tmax ? t : -1.0; +} +``` + +### 2. Disney BRDF Path Tracing +```glsl +struct Material { vec3 albedo; float metallic, roughness; }; + +float D_GGX(float a2, float NoH) { + float d = NoH * NoH * (a2 - 1.0) + 1.0; + return a2 / (PI * d * d); +} +float G_Smith(float NoV, float NoL, float a2) { + float g1 = (2.0 * NoV) / (NoV + sqrt(a2 + (1.0 - a2) * NoV * NoV)); + float g2 = (2.0 * NoL) / (NoL + sqrt(a2 + (1.0 - a2) * NoL * NoL)); + return g1 * g2; +} +vec3 SampleGGXVNDF(vec3 V, float ax, float ay, float r1, float r2) { + vec3 Vh = normalize(vec3(ax * V.x, ay * V.y, V.z)); + float lensq = Vh.x * Vh.x + Vh.y * Vh.y; + vec3 T1 = lensq > 0. ? vec3(-Vh.y, Vh.x, 0) * inversesqrt(lensq) : vec3(1, 0, 0); + vec3 T2 = cross(Vh, T1); + float r = sqrt(r1), phi = 2.0 * PI * r2; + float t1 = r * cos(phi), t2 = r * sin(phi); + float s = 0.5 * (1.0 + Vh.z); + t2 = (1.0 - s) * sqrt(1.0 - t1 * t1) + s * t2; + vec3 Nh = t1 * T1 + t2 * T2 + sqrt(max(0., 1. - t1*t1 - t2*t2)) * Vh; + return normalize(vec3(ax * Nh.x, ay * Nh.y, max(0., Nh.z))); +} +``` + +### 3. Depth of Field +```glsl +#define APERTURE 0.12 +#define FOCUS_DIST 8.0 + +vec2 uniformDisk() { + vec2 r = vec2(frand(), frand()); + return sqrt(r.y) * vec2(cos(6.2831853 * r.x), sin(6.2831853 * r.x)); +} +// After generating the ray: +vec3 focalPoint = ro + rd * FOCUS_DIST; +vec3 offset = ca * vec3(uniformDisk() * APERTURE, 0.); +ro += offset; +rd = normalize(focalPoint - ro); +``` + +### 4. MIS (Multiple Importance Sampling) +```glsl +float misWeight(float pdfA, float pdfB) { + float a2 = pdfA * pdfA, b2 = pdfB * pdfB; + return a2 / (a2 + b2); +} +// BRDF sample hits light -> misWeight(brdfPdf, lightPdf) +// Light sample -> misWeight(lightPdf, brdfPdf) +``` + +### 5. Volumetric Path Tracing (Participating Media) +```glsl +vec3 transmittance = exp(-extinction * distance); +float scatterDist = -log(frand()) / extinctionMajorant; +if (scatterDist < hitDist) { + pos += ray.d * scatterDist; + ray.d = uniformSphereSample(); // or Henyey-Greenstein + throughput *= albedo; +} +``` + +## Performance & Composition + +- 1-4 spp per frame + inter-frame accumulation for convergence; Russian roulette from bounce 3-4, survival probability = max throughput component +- NEE significantly accelerates small light sources; offset along normal by 1e-3~1e-4 or record hit ID to prevent self-intersection +- `min(color, 10.)` to prevent fireflies; SDF limited to 128-256 steps + reasonable tmax; integer hash preferred over sin-hash +- **Composition**: SDF modeling / HDR environment maps / Disney BRDF (GGX+VNDF) / volume rendering (Beer-Lambert) / spectral rendering (Sellmeier+CIE XYZ) / TAA (temporal reprojection) + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/path-tracing-gi.md) diff --git a/skills/shader-dev/techniques/polar-uv-manipulation.md b/skills/shader-dev/techniques/polar-uv-manipulation.md new file mode 100644 index 0000000..7753f78 --- /dev/null +++ b/skills/shader-dev/techniques/polar-uv-manipulation.md @@ -0,0 +1,373 @@ +## WebGL2 Adaptation Requirements + +Code templates in this document use ShaderToy GLSL style. When generating standalone HTML pages, you must adapt to WebGL2: + +- Use `canvas.getContext("webgl2")` +- **IMPORTANT: Version directive must strictly be on the first line**: When injecting shader code into HTML, ensure nothing precedes `#version 300 es` — no newlines, spaces, comments, or other characters. Common pitfall: accidentally adding `\n` when concatenating template strings, causing the version directive to appear on line 2-3 +- First line of shader: `#version 300 es`, add `precision highp float;` for fragment shaders +- Vertex shader: `attribute` → `in`, `varying` → `out` +- Fragment shader: `varying` → `in`, `gl_FragColor` → custom `out vec4 fragColor`, `texture2D()` → `texture()` +- ShaderToy's `void mainImage(out vec4 fragColor, in vec2 fragCoord)` must be adapted to standard `void main()` entry + +**IMPORTANT: GLSL Type Strictness Warning**: +- `vec2 = float` is illegal: types must match exactly, e.g., `float r = length(uv)` not `vec2 r = length(uv)` +- Function return types must match: commonly used `fbm()` / `noise()` return `float`, cannot be assigned to `vec2` +- If you need a vec2 type, use `vec2(fbm(...), fbm(...))` or `vec2(value)` constructor + +# Polar Coordinates & UV Manipulation + +## Use Cases +- Radially symmetric effects: flowers, kaleidoscopes, gears, radial patterns +- Spiral patterns: galaxies, vortices, spiral staircases +- Ring/tunnel effects: tube flying, torus twisting, circular UI elements +- Polar coordinate shapes: cardioid, rose curves, stars, and other shapes defined by r(θ) +- Vortex animations: swirls, rotational warping, card game backgrounds (e.g., Balatro) +- Fractal/repetitive structures: recursive symmetric patterns based on angular subdivision + +## Core Principles + +Polar coordinates convert (x, y) to (r, θ): +- **r = length(p)** — distance to origin +- **θ = atan(y, x)** — angle from positive x-axis, range [-π, π] + +Inverse transform: x = r·cos(θ), y = r·sin(θ) + +Manipulation effects: +- Modifying θ → rotation, warping, kaleidoscope +- Modifying r → scaling, radial ripples +- θ += f(r) → spiral effect + +| Spiral Type | Equation | Code | +|------------|----------|------| +| Archimedean spiral | r = a + bθ | `theta += radius` | +| Logarithmic spiral | r = ae^(bθ) | `theta += log(radius)` | +| Rose curve | r = cos(nθ) | `r - A*sin(n*theta)` | + +## Implementation Steps + +### Step 1: UV Normalization and Centering +```glsl +// Range [-1, 1], most commonly used +vec2 uv = (2.0 * fragCoord - iResolution.xy) / min(iResolution.x, iResolution.y); + +// Range [-aspect, aspect] x [-1, 1] +vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; + +// Pixelated style (Balatro style) +float pixel_size = length(iResolution.xy) / PIXEL_FILTER; +vec2 uv = (floor(fragCoord * (1.0/pixel_size)) * pixel_size - 0.5*iResolution.xy) / length(iResolution.xy); +``` + +### Step 2: Cartesian → Polar Coordinates +```glsl +float r = length(uv); +float theta = atan(uv.y, uv.x); // [-PI, PI] + +// Reusable function +vec2 toPolar(vec2 p) { return vec2(length(p), atan(p.y, p.x)); } + +// Normalized angle to [0, 1] +vec2 polar = vec2(atan(uv.y, uv.x) / 6.283 + 0.5, length(uv)); +``` + +### Step 3: Polar Space Operations + +**3a. Radial Swirl** +```glsl +float spin_amount = 0.25; +float new_theta = theta - spin_amount * 20.0 * r; +``` + +**3b. Angular Twist** +```glsl +float twist_angle = theta + 2.0 * iTime + sin(theta) * sin(iTime) * 3.14159; +``` + +**3c. Archimedean Spiral** +```glsl +vec2 spiral_uv = vec2(theta_normalized, r); +spiral_uv.y -= spiral_uv.x; // Unwrap into spiral band +``` + +**3d. Logarithmic Spiral** +```glsl +float shear = 2.0 * log(r); +float c = cos(shear), s = sin(shear); +mat2 spiral_mat = mat2(c, -s, s, c); +``` + +**3e. Kaleidoscope** +```glsl +float rep = 12.0; // Number of symmetry axes +float sector = TAU / rep; +float a = polar.y; +float c_idx = floor((a + sector * 0.5) / sector); +a = mod(a + sector * 0.5, sector) - sector * 0.5; +a *= mod(c_idx, 2.0) * 2.0 - 1.0; // Mirror +``` + +**3f. Spiral Arm Compression** +```glsl +float NB_ARMS = 5.0; +float COMPR = 0.1; +float phase = NB_ARMS * (theta - shear); +theta = theta - COMPR * cos(phase); +float arm_density = 1.0 + NB_ARMS * COMPR * sin(phase); +``` + +### Step 4: Polar → Cartesian Reconstruction +```glsl +vec2 new_uv = vec2(r * cos(new_theta), r * sin(new_theta)); + +vec2 toRect(vec2 p) { return vec2(p.x * cos(p.y), p.x * sin(p.y)); } + +// Balatro-style round-trip (offset to screen center) +vec2 mid = (iResolution.xy / length(iResolution.xy)) / 2.0; +vec2 warped_uv = vec2(r * cos(new_theta) + mid.x, r * sin(new_theta) + mid.y) - mid; +``` + +### Step 5: Polar Coordinate Shape SDF +```glsl +// Cardioid +float a = atan(p.x, p.y) / 3.141593; // atan(x,y) makes the heart face upward +float h = abs(a); +float heart_r = (13.0*h - 22.0*h*h + 10.0*h*h*h) / (6.0 - 5.0*h); +float dist = r - heart_r; + +// Rose curve +float rose_dist = abs(r - A_coeff * sin(PETAL_FREQ * theta) - 0.5); + +// Rendering +float shape = smoothstep(0.01, -0.01, dist); +``` + +### Step 6: Coloring and Anti-Aliasing +```glsl +// fwidth adaptive anti-aliasing +float aa = smoothstep(-1.0, 1.0, value / fwidth(value)); + +// Resolution-based anti-aliasing +float aa_size = 2.0 / iResolution.y; +float edge = smoothstep(0.5 - aa_size, 0.5 + aa_size, value); + +// Radial gradient coloring +vec3 color = vec3(1.0, 0.4 * r, 0.3); +color *= 1.0 - 0.4 * r; + +// Inter-spiral-band anti-aliasing +float inter_spiral_aa = 1.0 - pow(abs(2.0 * fract(spiral_uv.y) - 1.0), 10.0); +``` + +## Complete Code Template + +```glsl +// === Polar Coordinates & UV Manipulation Complete Template === +// Paste directly into ShaderToy to run + +#define PI 3.14159265359 +#define TAU 6.28318530718 + +// ===== Adjustable Parameters ===== +#define MODE 0 // 0=swirl, 1=spiral, 2=kaleidoscope, 3=rose curve +#define SPIRAL_TYPE 0 // 0=Archimedean, 1=logarithmic (MODE=1) +#define NUM_ARMS 5.0 // Number of spiral arms (MODE=1) +#define KALEID_SEGMENTS 6.0 // Kaleidoscope segments (MODE=2) +#define PETAL_COUNT 5.0 // Number of petals (MODE=3) +#define SWIRL_STRENGTH 3.0 // Swirl intensity (MODE=0) +#define ANIM_SPEED 1.0 // Animation speed +#define COLOR_SCHEME 0 // 0=warm, 1=cool, 2=rainbow + +vec2 toPolar(vec2 p) { + return vec2(length(p), atan(p.y, p.x)); +} + +vec2 toRect(vec2 p) { + return vec2(p.x * cos(p.y), p.x * sin(p.y)); +} + +vec2 kaleidoscope(vec2 polar, float segments) { + float sector = TAU / segments; + float a = polar.y; + float c = floor((a + sector * 0.5) / sector); + a = mod(a + sector * 0.5, sector) - sector * 0.5; + a *= mod(c, 2.0) * 2.0 - 1.0; + return vec2(polar.x, a); +} + +vec3 getColor(float t, int scheme) { + if (scheme == 1) return 0.5 + 0.5 * cos(TAU * (t + vec3(0.0, 0.33, 0.67))); + if (scheme == 2) return 0.5 + 0.5 * cos(TAU * t + vec3(0.0, 2.1, 4.2)); + return vec3(1.0, 0.4 + 0.4 * cos(t * TAU), 0.3 + 0.2 * sin(t * TAU)); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = (2.0 * fragCoord - iResolution.xy) / min(iResolution.x, iResolution.y); + vec2 polar = toPolar(uv); + float r = polar.x; + float theta = polar.y; + float t = iTime * ANIM_SPEED; + vec3 col = vec3(0.0); + float aa = 2.0 / iResolution.y; + + #if MODE == 0 + // --- Swirl mode --- + float swirl_theta = theta - SWIRL_STRENGTH * r + t; + vec2 warped = toRect(vec2(r, swirl_theta)); + warped *= 10.0; + float pattern = sin(warped.x) * cos(warped.y); + pattern += 0.5 * sin(2.0 * warped.x + t) * cos(2.0 * warped.y - t); + float val = smoothstep(-0.1, 0.1, pattern); + col = mix( + getColor(r * 0.5, COLOR_SCHEME), + getColor(r * 0.5 + 0.5, COLOR_SCHEME), + val + ); + col *= exp(-r * 0.5); + + #elif MODE == 1 + // --- Spiral mode --- + #if SPIRAL_TYPE == 0 + float spiral = theta / TAU + 0.5; + float bands = spiral + r; + bands -= t * 0.1; + float arm = fract(bands * NUM_ARMS); + #else + float shear = 2.0 * log(max(r, 0.001)); + float phase = NUM_ARMS * (theta - shear); + float arm = 0.5 + 0.5 * cos(phase); + arm *= 1.0 + NUM_ARMS * 0.1 * sin(phase); + #endif + float brightness = smoothstep(0.0, 0.4, arm) * smoothstep(1.0, 0.6, arm); + col = getColor(theta / TAU + t * 0.05, COLOR_SCHEME) * brightness; + col *= exp(-r * r * 0.5); + col += 0.15 * exp(-r * r * 8.0); + + #elif MODE == 2 + // --- Kaleidoscope mode --- + vec2 kp = kaleidoscope(polar, KALEID_SEGMENTS); + vec2 rect = toRect(kp); + rect *= 4.0; + rect += vec2(t * 0.3, 0.0); + vec2 cell_id = floor(rect + 0.5); + vec2 cell_uv = fract(rect + 0.5) - 0.5; + float cell_hash = fract(sin(dot(cell_id, vec2(127.1, 311.7))) * 43758.5453); + float d = length(cell_uv); + float truchet = abs(d - 0.35); + if (cell_hash > 0.5) { + truchet = min(truchet, abs(length(cell_uv - 0.5) - 0.5)); + } else { + truchet = min(truchet, abs(length(cell_uv + 0.5) - 0.5)); + } + col = getColor(cell_hash + r * 0.2, COLOR_SCHEME); + col *= smoothstep(0.05, 0.0, truchet - 0.03); + col *= smoothstep(3.0, 0.0, r); + + #elif MODE == 3 + // --- Rose curve mode --- + float rose_r = 0.6 * cos(PETAL_COUNT * theta + t); + float dist = abs(r - abs(rose_r)); + float ribbon_width = 0.04; + float rose_shape = smoothstep(ribbon_width + aa, ribbon_width - aa, dist); + float depth = 0.5 + 0.5 * cos(PETAL_COUNT * theta + t); + col = getColor(theta / TAU, COLOR_SCHEME) * depth; + col *= rose_shape; + float center = smoothstep(0.08 + aa, 0.08 - aa, r); + col += getColor(0.5, COLOR_SCHEME) * center * 0.5; + #endif + + col = pow(col, vec3(1.0 / 2.2)); + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Variant 1: Dynamic Vortex Background (Balatro Style) +Cartesian→Polar→Cartesian round-trip + iterative domain warping +```glsl +float new_angle = atan(uv.y, uv.x) + speed + - SPIN_EASE * 20.0 * (SPIN_AMOUNT * uv_len + (1.0 - SPIN_AMOUNT)); +vec2 mid = (screenSize.xy / length(screenSize.xy)) / 2.0; +uv = vec2(uv_len * cos(new_angle) + mid.x, + uv_len * sin(new_angle) + mid.y) - mid; +uv *= 30.0; +for (int i = 0; i < 5; i++) { + uv2 += sin(max(uv.x, uv.y)) + uv; + uv += 0.5 * vec2(cos(5.1123 + 0.353*uv2.y + speed*0.131), + sin(uv2.x - 0.113*speed)); + uv -= cos(uv.x + uv.y) - sin(uv.x*0.711 - uv.y); +} +``` + +### Variant 2: Polar Torus Twist (Ring Twister Style) +Direct rendering in polar space, angular slicing to simulate 3D torus +```glsl +vec2 uvr = vec2(length(uv), atan(uv.y, uv.x) + PI); +uvr.x -= OUT_RADIUS; +float twist = uvr.y + 2.0*iTime + sin(uvr.y)*sin(iTime)*PI; +for (int i = 0; i < NUM_FACES; i++) { + float x0 = IN_RADIUS * sin(twist + TAU * float(i) / float(NUM_FACES)); + float x1 = IN_RADIUS * sin(twist + TAU * float(i+1) / float(NUM_FACES)); + vec4 face = slice(x0, x1, uvr); + col = mix(col, face.rgb, face.a); +} +``` + +### Variant 3: Galaxy / Logarithmic Spiral (Galaxy Style) +`log(r)` equiangular spiral + FBM noise + spiral arm compression +```glsl +float rho = length(uv); +float ang = atan(uv.y, uv.x); +float shear = 2.0 * log(rho); +mat2 R = mat2(cos(shear), -sin(shear), sin(shear), cos(shear)); +float phase = NB_ARMS * (ang - shear); +ang = ang - COMPR * cos(phase) + SPEED * t; +uv = rho * vec2(cos(ang), sin(ang)); +float gaz = fbm_noise(0.09 * R * uv); +``` + +### Variant 4: Archimedean Spiral Band (Wave Greek Frieze Style) +Polar unwrap into spiral band, creating vortex animation within the band +```glsl +vec2 U = vec2(atan(U.y, U.x)/TAU + 0.5, length(U)); +U.y -= U.x; // Archimedean unwrap +U.x = arc_length(ceil(U.y) + U.x) - iTime; // Arc length parameterization +vec2 cell_uv = fract(U) - 0.5; +float vortex = dot(cell_uv, + cos(vec2(-33.0, 0.0) + + 0.3 * (iTime + cell_id.x) + * max(0.0, 0.5 - length(cell_uv)))); +``` + +### Variant 5: Complex / Polar Duality (Jeweled Vortex Style) +Complex arithmetic replaces explicit trigonometric functions for conformal mapping +```glsl +float e = n * 2.0; +float a = atan(u.y, u.x) - PI/2.0; +float r = exp(log(length(u)) / e); // r^(1/e) +float sc = ceil(r - a/TAU); +float s = pow(sc + a/TAU, 2.0); +col += sin(cr + s/n * TAU / 2.0); +col *= cos(cr + s/n * TAU); +col *= pow(abs(sin((r - a/TAU) * PI)), abs(e) + 5.0); +``` + +## Performance & Composition + +### Performance Tips +- **Pole safety**: `float r = max(length(uv), 1e-6);` to avoid division by zero +- **Trigonometric optimization**: When both sin/cos are needed, use a rotation matrix `mat2 ROT(float a) { float c=cos(a),s=sin(a); return mat2(c,s,-s,c); }` +- **Kaleidoscope is naturally optimized**: All expensive computation happens in a single sector, visual complexity ×N +- **Loop control**: Rose curves and other multi-loop effects work well with 4-8 loops; don't go too high +- **Pixel downsampling**: `floor(fragCoord / pixel_size) * pixel_size` quantizes coordinates to reduce computation + +### Composition Tips +- **Polar + FBM**: Sample noise in transformed space → organic spiral textures +- **Polar + Truchet**: Lay Truchet tiles after kaleidoscope folding → geometric tunnel effects +- **Polar + SDF**: `r(θ)` defines contour + SDF boolean operations / glow +- **Polar + Checkerboard**: `sign(sin(u*PI*4.0)*cos(uvr.y*16.0))` → circular checkerboard +- **Polar + Post-Processing**: Gamma + vignette + contrast enhancement for improved visual quality + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/polar-uv-manipulation.md) diff --git a/skills/shader-dev/techniques/post-processing.md b/skills/shader-dev/techniques/post-processing.md new file mode 100644 index 0000000..776e2f2 --- /dev/null +++ b/skills/shader-dev/techniques/post-processing.md @@ -0,0 +1,788 @@ +## WebGL2 Adaptation Requirements + +**IMPORTANT: Critical Warning for Standalone HTML Deployment**: Post-processing effects require an input texture to work. When generating standalone HTML, you must: +1. Set `#define USE_DEMO_SCENE 1` to use the built-in demo scene (recommended), or +2. Pass a valid input texture to the `iChannel0` channel, otherwise the screen will be completely black +3. **Critical**: When USE_DEMO_SCENE=1, ensure the #else branch code does not reference non-existent uniforms (e.g., iChannel0) + +**IMPORTANT: GLSL Type Strictness Rules**: +- `vec2 = float` is illegal — must use `vec2(x, x)` or `vec2(x)` +- Function parameters must be defined before use; using a variable name in its own initializer is forbidden (e.g., `float w = filmicCurve(w, w)` is an error) +- Variables must be declared before use +- **#version must be the very first line of shader code**: No characters (including whitespace or comments) may precede `#version 300 es` +- **Code in preprocessor branches is still compiled**: Even if `#if USE_DEMO_SCENE` is true, the `#else` branch code is still compiled by the GPU — all branches must be valid GLSL code + +Code templates in this document use ShaderToy GLSL style. When generating standalone HTML pages, you must adapt to WebGL2: + +- Use `canvas.getContext("webgl2")` +- First line of shader: `#version 300 es`, add `precision highp float;` for fragment shaders +- Vertex shader: `attribute` → `in`, `varying` → `out` +- Fragment shader: `varying` → `in`, `gl_FragColor` → custom `out vec4 fragColor`, `texture2D()` → `texture()` +- ShaderToy's `void mainImage(out vec4 fragColor, in vec2 fragCoord)` must be adapted to standard `void main()` entry +- Must create Framebuffers and render to texture before post-processing + +### Complete WebGL2 Standalone HTML Template + +```html + + + + + Post-Processing Shader + + + + + + + + + + + + + + +``` + +### Multi-Pass Post-Processing HTML Template (with FBO) + +Bloom separable blur, TAA, multi-step post-processing pipelines, etc. require rendering to intermediate textures. The following skeleton demonstrates the pattern: render scene to FBO → post-processing reads FBO → output to screen: + +```html + + + + + Multi-Pass Post-Processing + + + + + + + +``` + +# Screen-Space Post-Processing Effects + +## Use Cases + +Screen-space image enhancement on already-rendered scenes: Tone Mapping, Bloom, Vignette, Chromatic Aberration, Motion Blur, DoF, FXAA/TAA, Color Grading, Film Grain, Lens Flare, etc. + +Typical pipeline order: Scene Rendering → AA → Bloom → Chromatic Aberration → Motion Blur/DoF → Tone Mapping → Color Grading → Contrast → Vignette → Film Grain → Gamma → Dithering. + +## Core Principles + +The essence of post-processing is **per-pixel transformation of an already-rendered image** — input is a framebuffer texture, output is the transformed color value. + +- **Tone Mapping**: HDR [0, ∞) → LDR [0, 1]. Reinhard `c/(1+c)`, Filmic Reinhard (white point/shoulder parameters), ACES (3×3 matrix + rational polynomial), generic rational polynomial +- **Gaussian Blur**: 2D Gaussian kernel is separable into two 1D passes, O(n²) → O(2n) +- **Bloom**: Bright-pass extraction → multi-level Gaussian blur → additive blend back to original +- **Vignette**: Brightness falloff based on pixel distance to center. Multiplicative or radial +- **Chromatic Aberration**: Sample the same texture at different scales for R/G/B channels + +## Implementation Steps + +### Step 1: Tone Mapping + +```glsl +// Reinhard +vec3 reinhard(vec3 color) { return color / (1.0 + color); } + +// Filmic Reinhard (W=white point, T2=shoulder parameter) +// IMPORTANT: GLSL critical rule: function parameters must be defined before use; using a variable name in its own initializer is forbidden +const float W = 1.2, T2 = 7.5; // adjustable +float filmic_reinhard_curve(float x) { + float q = (T2 * T2 + 1.0) * x * x; + return q / (q + x + T2 * T2); +} +vec3 filmic_reinhard(vec3 x) { + float w = filmic_reinhard_curve(W); // compute w using constant W first + return vec3(filmic_reinhard_curve(x.r), filmic_reinhard_curve(x.g), filmic_reinhard_curve(x.b)) / w; +} + +// ACES industry standard +vec3 aces_tonemap(vec3 color) { + mat3 m1 = mat3(0.59719,0.07600,0.02840, 0.35458,0.90834,0.13383, 0.04823,0.01566,0.83777); + mat3 m2 = mat3(1.60475,-0.10208,-0.00327, -0.53108,1.10813,-0.07276, -0.07367,-0.00605,1.07602); + vec3 v = m1 * color; + vec3 a = v * (v + 0.0245786) - 0.000090537; + vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081; + return clamp(m2 * (a / b), 0.0, 1.0); +} + +// Generic rational polynomial +vec3 rational_tonemap(vec3 x) { + float a=0.010, b=0.132, c=0.010, d=0.163, e=0.101; // adjustable + return (x * (a * x + b)) / (x * (c * x + d) + e); +} +``` + +### Step 2: Gamma Correction + +```glsl +color = pow(color, vec3(1.0 / 2.2)); // after tone mapping; ACES already includes gamma, skip this step +``` + +### Step 3: Contrast Enhancement (Hermite S-Curve) + +```glsl +color = clamp(color, 0.0, 1.0); +color = color * color * (3.0 - 2.0 * color); +// Controllable intensity: color = mix(color, color*color*(3.0-2.0*color), strength); +// smoothstep equivalent: color = smoothstep(-0.025, 1.0, color); +``` + +### Step 4: Color Grading + +```glsl +color = color * vec3(1.11, 0.89, 0.79); // per-channel multiply (warm tone), adjustable +color = pow(color, vec3(1.3, 1.2, 1.0)); // pow color grading, adjustable +// HSV hue shift: hsv.x = fract(hsv.x + 0.05); hsv.y *= 1.1; +// Desaturation: color = mix(color, vec3(dot(color, vec3(0.299,0.587,0.114))), 0.2); +``` + +### Step 5: Vignette + +```glsl +// Option A: Multiplicative +vec2 q = fragCoord / iResolution.xy; +float vignette = pow(16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y), 0.25); +color *= 0.5 + 0.5 * vignette; + +// Option B: Radial distance +vec2 centered = (uv - 0.5) * vec2(iResolution.x / iResolution.y, 1.0); +float vig = mix(1.0, max(0.0, 1.0 - pow(length(centered)/1.414 * 0.6, 3.0)), 0.5); +color *= vig; + +// Option C: Inverse quadratic falloff +vec2 p = 1.0 - 2.0 * fragCoord / iResolution.xy; +p.y *= iResolution.y / iResolution.x; +float vig2 = 1.25 / (1.1 + 1.1 * dot(p, p)); vig2 *= vig2; +color *= mix(1.0, smoothstep(0.1, 1.1, vig2), 0.25); +``` + +### Step 6: Gaussian Blur + +```glsl +float normpdf(float x, float sigma) { + return 0.39894 * exp(-0.5 * x * x / (sigma * sigma)) / sigma; +} +vec3 gaussianBlur(sampler2D tex, vec2 fragCoord, vec2 resolution) { + const int KERNEL_SIZE = 11, HALF = 5; // adjustable: KERNEL_SIZE must be odd + float sigma = 7.0; // adjustable + float kernel[KERNEL_SIZE]; float Z = 0.0; + for (int j = 0; j <= HALF; ++j) + kernel[HALF + j] = kernel[HALF - j] = normpdf(float(j), sigma); + for (int j = 0; j < KERNEL_SIZE; ++j) Z += kernel[j]; + vec3 result = vec3(0.0); + for (int i = -HALF; i <= HALF; ++i) + for (int j = -HALF; j <= HALF; ++j) + result += kernel[HALF+j] * kernel[HALF+i] + * texture(tex, (fragCoord + vec2(float(i), float(j))) / resolution).rgb; + return result / (Z * Z); +} +``` + +### Step 7: Bloom (Single Pass, Hardware Mipmap) + +```glsl +vec3 simpleBloom(sampler2D tex, vec2 uv) { + vec3 bloom = vec3(0.0); float tw = 0.0; float maxB = 5.0; // adjustable + for (int x = -1; x <= 1; x++) + for (int y = -1; y <= 1; y++) { + vec2 off = vec2(float(x), float(y)) / iResolution.xy; float w = 1.0; + bloom += w * min(vec3(maxB), textureLod(tex, uv+off*exp2(5.0), 5.0).rgb); tw += w; + bloom += w * min(vec3(maxB), textureLod(tex, uv+off*exp2(6.0), 6.0).rgb); tw += w; + bloom += w * min(vec3(maxB), textureLod(tex, uv+off*exp2(7.0), 7.0).rgb); tw += w; + } + return pow(bloom / tw, vec3(1.5)) * 0.3; // adjustable: gamma and intensity +} +// Usage: color = color * 0.8 + simpleBloom(iChannel0, uv); +``` + +### Step 8: Chromatic Aberration + +```glsl +#define CA_SAMPLES 8 // adjustable +#define CA_STRENGTH 0.003 // adjustable +vec3 chromaticAberration(sampler2D tex, vec2 uv) { + vec2 center = uv - 0.5; vec3 color = vec3(0.0); + float rf = 1.0, gf = 1.0, bf = 1.0, f = 1.0 / float(CA_SAMPLES); + for (int i = 0; i < CA_SAMPLES; ++i) { + color.r += f * texture(tex, 0.5 - 0.5 * (center * 2.0 * rf)).r; + color.g += f * texture(tex, 0.5 - 0.5 * (center * 2.0 * gf)).g; + color.b += f * texture(tex, 0.5 - 0.5 * (center * 2.0 * bf)).b; + rf *= 1.0 - CA_STRENGTH; gf *= 1.0 - CA_STRENGTH*0.3; bf *= 1.0 + CA_STRENGTH*0.4; + } + return clamp(color, 0.0, 1.0); +} +``` + +### Step 9: Film Grain + +```glsl +float hash(float c) { return fract(sin(dot(c, vec2(12.9898, 78.233))) * 43758.5453); } +#define GRAIN_STRENGTH 0.012 // adjustable +color += vec3(GRAIN_STRENGTH * hash(length(fragCoord / iResolution.xy) + iTime)); + +// Bayer matrix ordered dithering (eliminates color banding) +const mat4 bayerMatrix = mat4( + vec4(0.,8.,2.,10.), vec4(12.,4.,14.,6.), vec4(3.,11.,1.,9.), vec4(15.,7.,13.,5.)); +float orderedDither(vec2 fc) { + return (bayerMatrix[int(fc.x)&3][int(fc.y)&3] + 1.0) / 17.0; +} +color += (orderedDither(fragCoord) - 0.5) * 4.0 / 255.0; +``` + +### Step 10: Demo Scene (Required for Standalone HTML!) + +**IMPORTANT: Critical Warning**: Standalone HTML deployment must provide an input texture, otherwise post-processing effects will output solid black. + +```glsl +// Demo scene fallback: used when no valid input texture is available +vec3 demoScene(vec2 uv, float time) { + // Dynamic gradient background + vec3 col = 0.5 + 0.5 * cos(time + uv.xyx + vec3(0, 2, 4)); + + // Center glowing sphere (for testing bloom) + float d = length(uv - 0.5) - 0.15; + col += vec3(2.0) * smoothstep(0.02, 0.0, d); // extremely bright region + + // Moving highlight bar (for testing bloom bleed) + float bar = step(0.48, uv.y) * step(uv.y, 0.52); + bar *= step(0.0, sin(uv.x * 10.0 - time * 2.0)); + col += vec3(1.5, 0.8, 0.3) * bar; + + // Colored blocks (for testing chromatic aberration and tone mapping) + vec2 id = floor(uv * 4.0); + float rand = fract(sin(dot(id, vec2(12.9898, 78.233))) * 43758.5453); + vec2 rect = fract(uv * 4.0); + float box = step(0.1, rect.x) * step(rect.x, 0.9) * step(0.1, rect.y) * step(rect.y, 0.9); + col += vec3(rand, 1.0 - rand, 0.5) * box * 0.5; + + return col; +} +``` + +### Step 10: Motion Blur + +```glsl +#define MB_SAMPLES 32 // adjustable +#define MB_STRENGTH 0.25 // adjustable +vec3 motionBlur(sampler2D tex, vec2 uv, vec2 velocity) { + vec2 dir = velocity * MB_STRENGTH; vec3 color = vec3(0.0); float tw = 0.0; + for (int i = 0; i < MB_SAMPLES; i++) { + float t = float(i) / float(MB_SAMPLES - 1), w = 1.0 - t; + color += w * textureLod(tex, uv + dir * t, 0.0).rgb; tw += w; + } + return color / tw; +} +``` + +### Step 11: Depth of Field + +```glsl +#define DOF_SAMPLES 64 +#define DOF_FOCAL_LENGTH 0.03 +float getCoC(float depth, float focusDist) { + float aperture = min(1.0, focusDist * focusDist * 0.5); + return abs(aperture * (DOF_FOCAL_LENGTH * (depth - focusDist)) + / (depth * (focusDist - DOF_FOCAL_LENGTH))); +} +float goldenAngle = 3.14159265 * (3.0 - sqrt(5.0)); +vec3 depthOfField(sampler2D tex, vec2 uv, float depth, float focusDist) { + float coc = getCoC(depth, focusDist); + vec3 result = texture(tex, uv).rgb * max(0.001, coc); + float tw = max(0.001, coc); + for (int i = 1; i < DOF_SAMPLES; i++) { + float fi = float(i); + float theta = fi * goldenAngle * float(DOF_SAMPLES); + float r = coc * sqrt(fi) / sqrt(float(DOF_SAMPLES)); + vec2 tapUV = uv + vec2(sin(theta), cos(theta)) * r; + vec4 s = textureLod(tex, tapUV, 0.0); + float w = max(0.001, getCoC(s.w, focusDist)); + result += s.rgb * w; tw += w; + } + return result / tw; +} +``` + +### Step 12: FXAA + +```glsl +vec3 fxaa(sampler2D tex, vec2 fragCoord, vec2 resolution) { + vec2 pp = 1.0 / resolution; + vec4 color = texture(tex, fragCoord * pp); + vec3 luma = vec3(0.299, 0.587, 0.114); + float lumaNW = dot(texture(tex, (fragCoord+vec2(-1.,-1.))*pp).rgb, luma); + float lumaNE = dot(texture(tex, (fragCoord+vec2( 1.,-1.))*pp).rgb, luma); + float lumaSW = dot(texture(tex, (fragCoord+vec2(-1., 1.))*pp).rgb, luma); + float lumaSE = dot(texture(tex, (fragCoord+vec2( 1., 1.))*pp).rgb, luma); + float lumaM = dot(color.rgb, luma); + float lumaMin = min(lumaM, min(min(lumaNW,lumaNE), min(lumaSW,lumaSE))); + float lumaMax = max(lumaM, max(max(lumaNW,lumaNE), max(lumaSW,lumaSE))); + vec2 dir = vec2(-((lumaNW+lumaNE)-(lumaSW+lumaSE)), ((lumaNW+lumaSW)-(lumaNE+lumaSE))); + float dirReduce = max((lumaNW+lumaNE+lumaSW+lumaSE)*0.03125, 1.0/128.0); + dir = clamp(dir * 2.5/(min(abs(dir.x),abs(dir.y))+dirReduce), vec2(-8.0), vec2(8.0)) * pp; + vec3 rgbA = 0.5 * (texture(tex, fragCoord*pp+dir*(1./3.-0.5)).rgb + + texture(tex, fragCoord*pp+dir*(2./3.-0.5)).rgb); + vec3 rgbB = rgbA*0.5 + 0.25*(texture(tex, fragCoord*pp+dir*-0.5).rgb + + texture(tex, fragCoord*pp+dir*0.5).rgb); + float lumaB = dot(rgbB, luma); + return (lumaB < lumaMin || lumaB > lumaMax) ? rgbA : rgbB; +} +``` + +## Complete Code Template + +Can be run directly in ShaderToy. `iChannel0` is the scene texture. + +**IMPORTANT: Important Warning**: For standalone HTML deployment, you must: +1. Pass a valid input texture to iChannel0 (or uChannel0) +2. Or set `#define USE_DEMO_SCENE 1` to use the built-in demo scene + +```glsl +// Post-Processing Pipeline — ShaderToy Template +#define ENABLE_TONEMAP 1 +#define ENABLE_BLOOM 1 +#define ENABLE_CA 1 +#define ENABLE_VIGNETTE 1 +#define ENABLE_GRAIN 1 +#define ENABLE_CONTRAST 1 +#define USE_DEMO_SCENE 1 // set to 1 to use built-in demo scene (required for standalone HTML) +#define TONEMAP_MODE 2 // 0=Reinhard, 1=Filmic, 2=ACES +#define BRIGHTNESS 1.0 +#define WHITE_POINT 1.2 +#define SHOULDER 7.5 +#define BLOOM_STRENGTH 0.08 +#define BLOOM_LOD_START 4.0 +#define COLOR_TINT vec3(1.11, 0.89, 0.79) +#define CA_SAMPLES 8 +#define CA_INTENSITY 0.003 +#define VIG_POWER 0.25 +#define GRAIN_AMOUNT 0.012 + +float hash11(float p) { return fract(sin(p * 12.9898) * 43758.5453); } + +// Demo scene fallback: used when no input texture is available +vec3 demoScene(vec2 uv, float time) { + // Dynamic gradient background + vec3 col = 0.5 + 0.5 * cos(time + uv.xyx + vec3(0, 2, 4)); + // Center glowing sphere (for testing bloom) + float d = length(uv - 0.5) - 0.15; + col += vec3(2.0) * smoothstep(0.02, 0.0, d); + // Moving highlight bar (for testing bloom bleed) + float bar = step(0.48, uv.y) * step(uv.y, 0.52); + bar *= step(0.0, sin(uv.x * 10.0 - time * 2.0)); + col += vec3(1.5, 0.8, 0.3) * bar; + // Colored blocks (for testing chromatic aberration and tone mapping) + vec2 id = floor(uv * 4.0); + float rand = fract(sin(dot(id, vec2(12.9898, 78.233))) * 43758.5453); + vec2 rect = fract(uv * 4.0); + float box = step(0.1, rect.x) * step(rect.x, 0.9) * step(0.1, rect.y) * step(rect.y, 0.9); + col += vec3(rand, 1.0 - rand, 0.5) * box * 0.5; + return col; +} + +vec3 tonemapReinhard(vec3 c) { return c / (1.0 + c); } +// IMPORTANT: Critical: filmicCurve takes only one parameter x; w is computed externally via WHITE_POINT +float filmicCurve(float x) { + float q = (SHOULDER*SHOULDER+1.0)*x*x; return q/(q+x+SHOULDER*SHOULDER); +} +vec3 tonemapFilmic(vec3 c) { + float w = filmicCurve(WHITE_POINT); // compute w using WHITE_POINT constant first + return vec3(filmicCurve(c.r), filmicCurve(c.g), filmicCurve(c.b)) / w; +} +vec3 tonemapACES(vec3 color) { + mat3 m1 = mat3(0.59719,0.07600,0.02840, 0.35458,0.90834,0.13383, 0.04823,0.01566,0.83777); + mat3 m2 = mat3(1.60475,-0.10208,-0.00327, -0.53108,1.10813,-0.07276, -0.07367,-0.00605,1.07602); + vec3 v = m1*color; + vec3 a = v*(v+0.0245786)-0.000090537; + vec3 b = v*(0.983729*v+0.4329510)+0.238081; + return clamp(m2*(a/b), 0.0, 1.0); +} +vec3 applyTonemap(vec3 c) { + c *= BRIGHTNESS; + #if TONEMAP_MODE == 0 + return tonemapReinhard(c); + #elif TONEMAP_MODE == 1 + return tonemapFilmic(c); + #else + return tonemapACES(c); + #endif +} + +vec3 sampleBloom(sampler2D tex, vec2 uv) { + vec3 bloom = vec3(0.0); float tw = 0.0; + for (int x = -1; x <= 1; x++) + for (int y = -1; y <= 1; y++) { + vec2 off = vec2(float(x),float(y))/iResolution.xy; float w = 1.0; + bloom += w*textureLod(tex, uv+off*exp2(BLOOM_LOD_START), BLOOM_LOD_START).rgb; + bloom += w*textureLod(tex, uv+off*exp2(BLOOM_LOD_START+1.0), BLOOM_LOD_START+1.0).rgb; + bloom += w*textureLod(tex, uv+off*exp2(BLOOM_LOD_START+2.0), BLOOM_LOD_START+2.0).rgb; + tw += w*3.0; + } + return bloom / tw; +} + +vec3 applyChromaticAberration(sampler2D tex, vec2 uv) { + vec2 center = 1.0 - 2.0*uv; vec3 color = vec3(0.0); + float rf=1.0, gf=1.0, bf=1.0, f=1.0/float(CA_SAMPLES); + for (int i = 0; i < CA_SAMPLES; ++i) { + color.r += f*texture(tex, 0.5-0.5*(center*rf)).r; + color.g += f*texture(tex, 0.5-0.5*(center*gf)).g; + color.b += f*texture(tex, 0.5-0.5*(center*bf)).b; + rf *= 1.0-CA_INTENSITY; gf *= 1.0-CA_INTENSITY*0.3; bf *= 1.0+CA_INTENSITY*0.4; + } + return clamp(color, 0.0, 1.0); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + + // Get input color: demo scene or input texture + #if USE_DEMO_SCENE + vec3 color = demoScene(uv, iTime); + #else + #if ENABLE_CA + vec3 color = applyChromaticAberration(iChannel0, uv); + #else + vec3 color = texture(iChannel0, uv).rgb; + #endif + #endif + + #if ENABLE_BLOOM && !USE_DEMO_SCENE + color += sampleBloom(iChannel0, uv) * BLOOM_STRENGTH; + #else + // In demo scene mode, use simplified bloom sampling from itself + #if ENABLE_BLOOM + vec3 bloom = vec3(0.0); float tw = 0.0; + for (int x = -1; x <= 1; x++) + for (int y = -1; y <= 1; y++) { + vec2 off = vec2(float(x),float(y))/iResolution.xy * 0.02; + vec3 s = demoScene(uv + off, iTime); + float w = 1.0; + bloom += w * min(vec3(5.0), s); tw += w; + } + color += bloom / tw * BLOOM_STRENGTH; + #endif + #endif + + color *= COLOR_TINT; + #if ENABLE_TONEMAP + #if TONEMAP_MODE == 2 + color = applyTonemap(color); + #else + color = applyTonemap(color); + color = pow(color, vec3(1.0/2.2)); + #endif + #else + color = pow(color, vec3(1.0/2.2)); + #endif + #if ENABLE_CONTRAST + color = clamp(color, 0.0, 1.0); + color = color*color*(3.0-2.0*color); + #endif + #if ENABLE_VIGNETTE + vec2 q = fragCoord/iResolution.xy; + color *= 0.5 + 0.5*pow(16.0*q.x*q.y*(1.0-q.x)*(1.0-q.y), VIG_POWER); + #endif + #if ENABLE_GRAIN + color += GRAIN_AMOUNT * hash11(dot(uv, vec2(12.9898,78.233)) + iTime); + #endif + fragColor = vec4(clamp(color, 0.0, 1.0), 1.0); +} +``` + +## Common Variants + +### Variant 1: Multi-Pass Separable Bloom + +```glsl +// Buffer A: Horizontal Gaussian blur + bright-pass +#define BLOOM_THRESHOLD vec3(0.2) +#define BLOOM_DOWNSAMPLE 3 +#define BLUR_RADIUS 16 +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + ivec2 xy = ivec2(fragCoord); + if (xy.x >= int(iResolution.x)/BLOOM_DOWNSAMPLE) { fragColor = vec4(0); return; } + vec3 sum = vec3(0.0); float tw = 0.0; + for (int k = -BLUR_RADIUS; k <= BLUR_RADIUS; ++k) { + vec3 texel = max(vec3(0.0), texelFetch(iChannel0, (xy+ivec2(k,0))*BLOOM_DOWNSAMPLE, 0).rgb - BLOOM_THRESHOLD); + float w = exp(-8.0 * pow(abs(float(k))/float(BLUR_RADIUS), 2.0)); + sum += texel*w; tw += w; + } + fragColor = vec4(sum/tw, 1.0); +} +// Buffer B: Vertical blur, same as above but with direction changed to ivec2(0, k) +``` + +### Variant 2: ACES + Full Color Pipeline (with Built-in Gamma) + +```glsl +vec3 aces_tonemap(vec3 color) { + mat3 m1 = mat3(0.59719,0.07600,0.02840, 0.35458,0.90834,0.13383, 0.04823,0.01566,0.83777); + mat3 m2 = mat3(1.60475,-0.10208,-0.00327, -0.53108,1.10813,-0.07276, -0.07367,-0.00605,1.07602); + vec3 v = m1*color; + vec3 a = v*(v+0.0245786)-0.000090537; + vec3 b = v*(0.983729*v+0.4329510)+0.238081; + return pow(clamp(m2*(a/b), 0.0, 1.0), vec3(1.0/2.2)); +} +``` + +### Variant 3: DoF + Motion Blur Combination + +```glsl +for (int i = 1; i < BLUR_TAPS; i++) { + float t = float(i)/float(BLUR_TAPS); + float randomT = hash(iTime + t + uv.x + uv.y*12.345); + vec2 tapUV = mix(currentUV, prevFrameUV, (randomT-0.5)*shutterAngle); // motion blur + float theta = t*goldenAngle*float(BLUR_TAPS); + float r = coc*sqrt(t*float(BLUR_TAPS))/sqrt(float(BLUR_TAPS)); + tapUV += vec2(sin(theta), cos(theta))*r; // DoF + vec4 tap = textureLod(sceneTex, tapUV, 0.0); + float w = max(0.001, getCoC(decodeDepth(tap.w), focusDistance)); + result += tap.rgb*w; totalWeight += w; +} +``` + +### Variant 4: TAA Temporal Anti-Aliasing + +```glsl +vec4 current = textureLod(currentFrame, uv - jitterOffset/iResolution.xy, 0.0); +vec3 vMin = vec3(1e5), vMax = vec3(-1e5); +for (int iy = -1; iy <= 1; iy++) + for (int ix = -1; ix <= 1; ix++) { + vec3 s = texelFetch(currentFrame, ivec2(fragCoord)+ivec2(ix,iy), 0).rgb; + vMin = min(vMin, s); vMax = max(vMax, s); + } +vec4 history = textureLod(historyBuffer, reprojectToPrevFrame(worldPos, prevViewProjMatrix), 0.0); +float blend = (all(greaterThanEqual(history.rgb, vMin)) && all(lessThanEqual(history.rgb, vMax))) ? 0.9 : 0.0; +color = mix(current.rgb, history.rgb, blend); +``` + +### Variant 5: Lens Flare + Starburst + +```glsl +#define NUM_APERTURE_BLADES 8.0 +vec2 toSun = normalize(sunScreenPos - uv); +float angle = atan(toSun.y, toSun.x); +float starburst = pow(0.5+0.5*cos(1.5*3.14159+angle*NUM_APERTURE_BLADES), + max(1.0, 500.0-sunDist*sunDist*501.0)); +float ghost = smoothstep(0.015, 0.0, length(ghostCenter-uv)-ghostRadius); +totalFlare += wavelengthToRGB(300.0+fract((length(ghostCenter-uv)-ghostRadius)*5.0)*500.0) * ghost * 0.25; +``` + +## Performance & Composition + +**Performance**: Separable blur 121→22 samples | `textureLod` hardware mipmap for free downsampling | Downsample 2-4x before blurring | Sample counts: MB 16-32, DoF 32-64, CA 4-8 | Inter-texel sampling = free bilinear | `#define` switches have zero cost | Use `mix`/`step`/`smoothstep` instead of branches + +**Composition**: Bloom+ToneMap (compute bloom in HDR space then tonemap, not reversible) | TAA+MB+DoF (shared sampling loop) | CA+Vignette+Grain (lens trio) | ColorGrading+ToneMap+Contrast (grade in linear space → HDR compression → gamma-space S-curve) | Bloom+LensFlare (shared bright-pass) | Multi-pass pipeline: BufA scene → BufB/C Bloom H/V → BufD TAA → Image compositing + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/post-processing.md) diff --git a/skills/shader-dev/techniques/procedural-2d-pattern.md b/skills/shader-dev/techniques/procedural-2d-pattern.md new file mode 100644 index 0000000..fcdb4ee --- /dev/null +++ b/skills/shader-dev/techniques/procedural-2d-pattern.md @@ -0,0 +1,346 @@ +# 2D Procedural Patterns + +## Use Cases +- Repeating/aperiodic 2D patterns: grids, hexagons, Truchet, interference patterns, kaleidoscopes, spirals, Lissajous +- Procedural backgrounds, UI textures, sci-fi HUD/radar +- Fractals, water caustics, and other natural phenomena +- Infinite detail, seamless tiling, parameter-driven visual effects + +## Core Principles + +2D procedural patterns = **domain transforms + distance fields + color mapping**: + +1. **Domain repetition**: `fract()`/`mod()` folds the infinite plane into repeating cells +2. **Cell identification**: `floor()` extracts integer coordinates as hash seeds, driving per-cell random variations +3. **Distance field (SDF)**: mathematical functions compute pixel-to-shape distance, `smoothstep` renders edges +4. **Color mapping**: cosine palette `a + b*cos(2pi(c*t+d))` or HSV +5. **Layer compositing**: multi-layer loop results blended via addition/multiplication/`mix` + +Key formulas: +```glsl +// UV normalization +uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y; +// Domain repetition +cell_uv = fract(uv * SCALE) - 0.5; +cell_id = floor(uv * SCALE); +// Cosine palette +col = a + b * cos(6.28318 * (c * t + d)); +// Hexagon SDF +hex(p) = max(dot(abs(p), vec2(0.5, 0.866025)), abs(p).x); +// 2D rotation +mat2(cos(a), -sin(a), sin(a), cos(a)); +``` + +## Implementation Steps + +### Step 1: UV Normalization +```glsl +vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y; +``` + +### Step 2: Domain Repetition +```glsl +#define SCALE 4.0 +vec2 cell_uv = fract(uv * SCALE) - 0.5; +vec2 cell_id = floor(uv * SCALE); +``` + +Hexagonal grid domain repetition: +```glsl +const vec2 s = vec2(1, 1.7320508); +vec4 hC = floor(vec4(p, p - vec2(0.5, 1.0)) / s.xyxy) + 0.5; +vec4 h = vec4(p - hC.xy * s, p - (hC.zw + 0.5) * s); +vec4 hex_data = dot(h.xy, h.xy) < dot(h.zw, h.zw) + ? vec4(h.xy, hC.xy) + : vec4(h.zw, hC.zw + vec2(0.5, 1.0)); +``` + +### Step 3: Per-Cell Randomization +```glsl +float hash21(vec2 p) { + return fract(sin(dot(p, vec2(141.173, 289.927))) * 43758.5453); +} +float rnd = hash21(cell_id); +float radius = 0.15 + 0.1 * rnd; +``` + +### Step 4: SDF Shape Drawing +```glsl +// Circle +float d = length(cell_uv) - radius; + +// Hexagon +float hex_sdf(vec2 p) { + p = abs(p); + return max(dot(p, vec2(0.5, 0.866025)), p.x); +} + +// Line segment +float line_sdf(vec2 a, vec2 b, vec2 p) { + vec2 pa = p - a, ba = b - a; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + return length(pa - ba * h); +} + +// Anti-aliased rendering +float shape = 1.0 - smoothstep(radius - 0.008, radius + 0.008, length(cell_uv)); +``` + +### Step 5: Polar Coordinate Rings/Arcs +```glsl +vec2 polar = vec2(length(uv), atan(uv.y, uv.x)); +float ring_id = floor(polar.x * NUM_RINGS + 0.5) / NUM_RINGS; +float ring = 1.0 - pow(abs(sin(polar.x * 3.14159 * NUM_RINGS)) * 1.25, 2.5); +float arc_end = polar.y + sin(iTime + ring_id * 5.5) * 1.52 - 1.5; +ring *= smoothstep(0.0, 0.05, arc_end); +``` + +### Step 6: Cosine Palette +```glsl +vec3 palette(float t) { + vec3 a = vec3(0.5, 0.5, 0.5); + vec3 b = vec3(0.5, 0.5, 0.5); + vec3 c = vec3(1.0, 1.0, 1.0); + vec3 d = vec3(0.263, 0.416, 0.557); + return a + b * cos(6.28318 * (c * t + d)); +} +``` + +### Step 7: Iterative Stacking & Glow +```glsl +#define NUM_LAYERS 4.0 +vec3 finalColor = vec3(0.0); +vec2 uv0 = uv; +for (float i = 0.0; i < NUM_LAYERS; i++) { + uv = fract(uv * 1.5) - 0.5; + float d = length(uv) * exp(-length(uv0)); + vec3 col = palette(length(uv0) + i * 0.4 + iTime * 0.4); + d = sin(d * 8.0 + iTime) / 8.0; + d = abs(d); + d = pow(0.01 / d, 1.2); + finalColor += col * d; +} +``` + +### Step 8: Trigonometric Interference +```glsl +#define MAX_ITER 5 +vec2 p = mod(uv * TAU, TAU) - 250.0; +vec2 i = p; +float c = 1.0; +float inten = 0.005; +for (int n = 0; n < MAX_ITER; n++) { + float t = iTime * (1.0 - 3.5 / float(n + 1)); + i = p + vec2(cos(t - i.x) + sin(t + i.y), + sin(t - i.y) + cos(t + i.x)); + c += 1.0 / length(vec2(p.x / (sin(i.x + t) / inten), + p.y / (cos(i.y + t) / inten))); +} +c /= float(MAX_ITER); +c = 1.17 - pow(c, 1.4); +vec3 colour = vec3(pow(abs(c), 8.0)); +``` + +### Step 9: Multi-Layer Depth Compositing +```glsl +#define NUM_DEPTH_LAYERS 4.0 +float m = 0.0; +for (float i = 0.0; i < 1.0; i += 1.0 / NUM_DEPTH_LAYERS) { + float z = fract(iTime * 0.1 + i); + float size = mix(15.0, 1.0, z); + float fade = smoothstep(0.0, 0.6, z) * smoothstep(1.0, 0.8, z); + m += fade * patternLayer(uv * size, i, iTime); +} +``` + +### Step 10: Post-Processing +```glsl +col = pow(clamp(col, 0.0, 1.0), vec3(1.0 / 2.2)); // Gamma +col = col * 0.6 + 0.4 * col * col * (3.0 - 2.0 * col); // Contrast S-curve +col = mix(col, vec3(dot(col, vec3(0.33))), -0.4); // Saturation +vec2 q = fragCoord / iResolution.xy; +col *= 0.5 + 0.5 * pow(16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y), 0.7); // Vignette +``` + +## Complete Code Template + +```glsl +// ====== 2D Procedural Pattern Template ====== +// Ready to run in ShaderToy + +#define SCALE 3.0 +#define NUM_LAYERS 4.0 +#define ZOOM_FACTOR 1.5 +#define GLOW_WIDTH 0.01 +#define GLOW_POWER 1.2 +#define WAVE_FREQ 8.0 +#define ANIM_SPEED 0.4 +#define RING_COUNT 10.0 + +vec3 palette(float t) { + vec3 a = vec3(0.5, 0.5, 0.5); + vec3 b = vec3(0.5, 0.5, 0.5); + vec3 c = vec3(1.0, 1.0, 1.0); + vec3 d = vec3(0.263, 0.416, 0.557); + return a + b * cos(6.28318 * (c * t + d)); +} + +float hash21(vec2 p) { + return fract(sin(dot(p, vec2(141.173, 289.927))) * 43758.5453); +} + +mat2 rot2(float a) { + float c = cos(a), s = sin(a); + return mat2(c, -s, s, c); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y; + vec2 uv0 = uv; + vec3 finalColor = vec3(0.0); + + for (float i = 0.0; i < NUM_LAYERS; i++) { + uv = fract(uv * ZOOM_FACTOR) - 0.5; + float d = length(uv) * exp(-length(uv0)); + vec3 col = palette(length(uv0) + i * 0.4 + iTime * ANIM_SPEED); + d = sin(d * WAVE_FREQ + iTime) / WAVE_FREQ; + d = abs(d); + d = pow(GLOW_WIDTH / d, GLOW_POWER); + finalColor += col * d; + } + + finalColor = pow(clamp(finalColor, 0.0, 1.0), vec3(1.0 / 2.2)); + finalColor = finalColor * 0.6 + 0.4 * finalColor * finalColor * (3.0 - 2.0 * finalColor); + vec2 q = fragCoord / iResolution.xy; + finalColor *= 0.5 + 0.5 * pow(16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y), 0.7); + + fragColor = vec4(finalColor, 1.0); +} +``` + +## Common Variants + +### Variant 1: Hexagonal Truchet Arcs +```glsl +float hex(vec2 p) { + p = abs(p); + return max(dot(p, vec2(0.5, 0.866025)), p.x); +} + +const vec2 s = vec2(1.0, 1.7320508); +vec4 getHex(vec2 p) { + vec4 hC = floor(vec4(p, p - vec2(0.5, 1.0)) / s.xyxy) + 0.5; + vec4 h = vec4(p - hC.xy * s, p - (hC.zw + 0.5) * s); + return dot(h.xy, h.xy) < dot(h.zw, h.zw) + ? vec4(h.xy, hC.xy) + : vec4(h.zw, hC.zw + vec2(0.5, 1.0)); +} + +// Truchet triple arcs +float r = 1.0; +vec2 q1 = p - vec2(0.0, r) / s; +vec2 q2 = rot2(6.28318 / 3.0) * p - vec2(0.0, r) / s; +vec2 q3 = rot2(6.28318 * 2.0 / 3.0) * p - vec2(0.0, r) / s; +float d = min(min(length(q1), length(q2)), length(q3)); +d = abs(d - 0.288675) - 0.1; +``` + +### Variant 2: Water Caustic Interference +```glsl +#define TAU 6.28318530718 +#define MAX_ITER 5 +vec2 p = mod(uv * TAU, TAU) - 250.0; +vec2 i = p; +float c = 1.0; +float inten = 0.005; +for (int n = 0; n < MAX_ITER; n++) { + float t = iTime * (1.0 - 3.5 / float(n + 1)); + i = p + vec2(cos(t - i.x) + sin(t + i.y), + sin(t - i.y) + cos(t + i.x)); + c += 1.0 / length(vec2(p.x / (sin(i.x + t) / inten), + p.y / (cos(i.y + t) / inten))); +} +c /= float(MAX_ITER); +c = 1.17 - pow(c, 1.4); +vec3 colour = vec3(pow(abs(c), 8.0)); +colour = clamp(colour + vec3(0.0, 0.35, 0.5), 0.0, 1.0); +``` + +### Variant 3: Polar Concentric Ring Arc Segments +```glsl +#define NUM_RINGS 20.0 +#define PALETTE vec3(0.0, 1.4, 2.0) + 1.5 +vec2 plr = vec2(length(p), atan(p.y, p.x)); +float id = floor(plr.x * NUM_RINGS + 0.5) / NUM_RINGS; +p *= rot2(id * 11.0); +p.y = abs(p.y); +float rz = 1.0 - pow(abs(sin(plr.x * 3.14159 * NUM_RINGS)) * 1.25, 2.5); +float arc = plr.y + sin(iTime + id * 5.5) * 1.52 - 1.5; +rz *= smoothstep(0.0, 0.05, arc); +vec3 col = (sin(PALETTE + id * 5.0 + iTime) * 0.5 + 0.5) * rz; +``` + +### Variant 4: Multi-Layer Depth Parallax Network +```glsl +#define NUM_DEPTH_LAYERS 4.0 +vec2 GetPos(vec2 id, vec2 offs, float t) { + float n = hash21(id + offs); + return offs + vec2(sin(t + n * 6.28), cos(t + fract(n * 100.0) * 6.28)) * 0.4; +} +float df_line(vec2 a, vec2 b, vec2 p) { + vec2 pa = p - a, ba = b - a; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + return length(pa - ba * h); +} +float m = 0.0; +for (float i = 0.0; i < 1.0; i += 1.0 / NUM_DEPTH_LAYERS) { + float z = fract(iTime * 0.1 + i); + float size = mix(15.0, 1.0, z); + float fade = smoothstep(0.0, 0.6, z) * smoothstep(1.0, 0.8, z); + m += fade * NetLayer(uv * size, i, iTime); +} +``` + +### Variant 5: Fractal Apollonian +```glsl +float apollian(vec4 p, float s) { + float scale = 1.0; + for (int i = 0; i < 7; ++i) { + p = -1.0 + 2.0 * fract(0.5 * p + 0.5); + float r2 = dot(p, p); + float k = s / r2; + p *= k; + scale *= k; + } + return abs(p.y) / scale; +} +vec4 pp = vec4(p.x, p.y, 0.0, 0.0) + offset; +pp.w = 0.125 * (1.0 - tanh(length(pp.xyz))); +float d = apollian(pp / 4.0, 1.2) * 4.0; +float hue = fract(0.75 * length(p) - 0.3 * iTime) + 0.3; +float sat = 0.75 * tanh(2.0 * length(p)); +vec3 col = hsv2rgb(vec3(hue, sat, 1.0)); +``` + +## Performance & Composition + +**Performance:** +- Iteration loops are the biggest bottleneck; `NUM_LAYERS` 4->8 halves performance; mobile should use 3 layers or fewer +- Use `step()`/`smoothstep()`/`mix()` instead of `if/else` +- Merge multiple SDFs with `min()`/`max()`, then apply a single `smoothstep` +- Precompute `sin`/`cos` pairs outside loops; write irrational constants as literal values +- `atan` is expensive; use `dot` approximation when only periodicity is needed +- LOD: reduce iterations for distant objects `int iters = int(mix(3.0, float(MAX_ITER), smoothstep(...)));` +- `smoothstep` is often better than `pow` and inherently clamps to [0,1] + +**Combinations:** +- **+ Noise**: `d += triangleNoise(uv * 10.0) * 0.05;` for organic erosion feel +- **+ Cross-hatch**: grayscale thresholds + `sin` lines to simulate hand-drawn style +- **+ SDF Boolean**: `min` (union) / `max` (intersection) / subtraction for complex geometry +- **+ Domain distortion**: `uv += 0.05 * vec2(sin(uv.y*5.+iTime), sin(uv.x*3.+iTime));` +- **+ Radial blur**: multi-sample average along polar coordinate direction +- **+ Pseudo-3D lighting**: SDF gradient as normal, add diffuse/specular for embossed look + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/procedural-2d-pattern.md) diff --git a/skills/shader-dev/techniques/procedural-noise.md b/skills/shader-dev/techniques/procedural-noise.md new file mode 100644 index 0000000..2970cb3 --- /dev/null +++ b/skills/shader-dev/techniques/procedural-noise.md @@ -0,0 +1,554 @@ +# Procedural Noise Skill + +## Use Cases + +Procedural noise is the most fundamental technique in real-time GPU graphics. It applies to natural phenomena (fire, clouds, water, lava), terrain generation, texture synthesis, volume rendering, motion effects, and more. + +Core idea: use mathematical functions to generate pseudo-random, spatially continuous signals on the GPU in real time, then produce multi-scale detail through FBM and domain warping. + +## Core Principles + +### Noise Functions + +Generate random values at integer lattice points, then smoothly interpolate between them. + +- **Value Noise**: random scalars at lattice points + bilinear Hermite interpolation. `N(p) = mix(mix(h00,h10,u), mix(h01,h11,u), v)` +- **Simplex Noise**: triangular lattice gradient dot products + radial falloff kernel. Skew `K1=(sqrt(3)-1)/2`, unskew `K2=(3-sqrt(3))/6`. Fewer lattice lookups, no axis-aligned artifacts. + +### Hash Functions + +Map integer coordinates to pseudo-random values: + +- **sin-based** (short but precision-sensitive): `fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453)` +- **sin-free** (cross-platform stable): `fract(p * 0.1031)` + dot mixing + fract + +### FBM (Fractal Brownian Motion) + +Multi-octave noise summation: `FBM(p) = sum of amplitude_i * noise(frequency_i * p)` + +- Lacunarity ~2.0, Gain ~0.5, inter-octave rotation to eliminate artifacts + +### Domain Warping + +Feed noise output back as coordinate offset: `fbm(p + fbm(p))` or cascaded `fbm(p + fbm(p + fbm(p)))` + +### FBM Variant Quick Reference + +| Variant | Formula | Effect | +|---------|---------|--------| +| Standard | `sum a*noise(p)` | Soft clouds | +| Ridged | `sum a*abs(noise(p))` | Sharp ridges/lightning | +| Sinusoidal ridged | `sum a*sin(noise(p)*k)` | Periodic ridges/lava | +| Erosion | `sum a*noise(p)/(1+dot(d,d))` | Realistic terrain | +| Ocean waves | `sum a*sea_octave(p)` | Peaked wave crests | + +## Implementation Code + +### Hash Functions + +```glsl +// Sin-free hash (Dave Hoskins) — cross-platform stable +float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * .1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +vec2 hash22(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); +} + +// Sin hash — shorter code, precision-sensitive on some GPUs +float hash(vec2 p) { + float h = dot(p, vec2(127.1, 311.7)); + return fract(sin(h) * 43758.5453123); +} + +vec2 hash2(vec2 p) { + p = vec2(dot(p, vec2(127.1, 311.7)), + dot(p, vec2(269.5, 183.3))); + return -1.0 + 2.0 * fract(sin(p) * 43758.5453123); +} +``` + +### Value Noise + +```glsl +// Hermite smooth bilinear interpolation +float noise(in vec2 x) { + vec2 p = floor(x); + vec2 f = fract(x); + f = f * f * (3.0 - 2.0 * f); + float a = hash(p + vec2(0.0, 0.0)); + float b = hash(p + vec2(1.0, 0.0)); + float c = hash(p + vec2(0.0, 1.0)); + float d = hash(p + vec2(1.0, 1.0)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} +``` + +### Simplex Noise + +```glsl +// 2D Simplex (skewed triangular grid + h^4 falloff kernel) +float noise(in vec2 p) { + const float K1 = 0.366025404; // (sqrt(3)-1)/2 + const float K2 = 0.211324865; // (3-sqrt(3))/6 + vec2 i = floor(p + (p.x + p.y) * K1); + vec2 a = p - i + (i.x + i.y) * K2; + vec2 o = (a.x > a.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); + vec2 b = a - o + K2; + vec2 c = a - 1.0 + 2.0 * K2; + vec3 h = max(0.5 - vec3(dot(a, a), dot(b, b), dot(c, c)), 0.0); + vec3 n = h * h * h * h * vec3( + dot(a, hash2(i + 0.0)), + dot(b, hash2(i + o)), + dot(c, hash2(i + 1.0)) + ); + return dot(n, vec3(70.0)); +} +``` + +### Standard FBM + +```glsl +#define OCTAVES 4 +#define GAIN 0.5 +mat2 m = mat2(1.6, 1.2, -1.2, 1.6); // rotation+scale, |m|=2.0, ~36.87 deg + +float fbm(vec2 p) { + float f = 0.0, a = 0.5; + for (int i = 0; i < OCTAVES; i++) { + f += a * noise(p); + p = m * p; + a *= GAIN; + } + return f; +} +``` + +Manually unrolled version (slightly varying lacunarity to break self-similarity): + +```glsl +const mat2 mtx = mat2(0.80, 0.60, -0.60, 0.80); +float fbm4(vec2 p) { + float f = 0.0; + f += 0.5000 * (-1.0 + 2.0 * noise(p)); p = mtx * p * 2.02; + f += 0.2500 * (-1.0 + 2.0 * noise(p)); p = mtx * p * 2.03; + f += 0.1250 * (-1.0 + 2.0 * noise(p)); p = mtx * p * 2.01; + f += 0.0625 * (-1.0 + 2.0 * noise(p)); + return f / 0.9375; +} +``` + +### Ridged FBM + +```glsl +// abs() produces V-shaped ridges at zero crossings +float fbm_ridged(in vec2 p) { + float z = 2.0, rz = 0.0; + for (float i = 1.0; i < 6.0; i++) { + rz += abs((noise(p) - 0.5) * 2.0) / z; + z *= 2.0; + p *= 2.0; + } + return rz; +} + +// Sinusoidal ridged variant — lava texture +// rz += (sin(noise(p) * 7.0) * 0.5 + 0.5) / z; +``` + +### Domain Warping + +```glsl +// Basic domain warping ("2D Clouds") +float q = fbm(uv * 0.5); +uv -= q - time; +float f = fbm(uv); + +// Classic three-level cascade +vec2 fbm4_2(vec2 p) { + return vec2(fbm4(p + vec2(1.0)), fbm4(p + vec2(6.2))); +} +float func(vec2 q, out vec2 o, out vec2 n) { + o = 0.5 + 0.5 * fbm4_2(q); + n = fbm6_2(4.0 * o); + vec2 p = q + 2.0 * n + 1.0; + float f = 0.5 + 0.5 * fbm4(2.0 * p); + f = mix(f, f * f * f * 3.5, f * abs(n.x)); + return f; +} + +// Dual-axis domain warping +float dualfbm(in vec2 p) { + vec2 p2 = p * 0.7; + vec2 basis = vec2(fbm(p2 - time * 1.6), fbm(p2 + time * 1.7)); + basis = (basis - 0.5) * 0.2; + p += basis; + return fbm(p * makem2(time * 0.2)); +} +``` + +### Fluid Noise + +```glsl +// Per-octave gradient displacement simulating fluid transport +#define FLOW_SPEED 0.6 +#define BASE_SPEED 1.9 +#define ADVECTION 0.77 +#define GRAD_SCALE 0.5 + +vec2 gradn(vec2 p) { + float ep = 0.09; + float gradx = noise(vec2(p.x + ep, p.y)) - noise(vec2(p.x - ep, p.y)); + float grady = noise(vec2(p.x, p.y + ep)) - noise(vec2(p.x, p.y - ep)); + return vec2(gradx, grady); +} + +float flow(in vec2 p) { + float z = 2.0, rz = 0.0; + vec2 bp = p; + for (float i = 1.0; i < 7.0; i++) { + p += time * FLOW_SPEED; + bp += time * BASE_SPEED; + vec2 gr = gradn(i * p * 0.34 + time * 1.0); + gr *= makem2(time * 6.0 - (0.05 * p.x + 0.03 * p.y) * 40.0); + p += gr * GRAD_SCALE; + rz += (sin(noise(p) * 7.0) * 0.5 + 0.5) / z; + p = mix(bp, p, ADVECTION); + z *= 1.4; + p *= 2.0; + bp *= 1.9; + } + return rz; +} +``` + +### Derivative FBM + +```glsl +// Value noise with analytic derivatives +vec3 noised(in vec2 x) { + vec2 p = floor(x); + vec2 f = fract(x); + vec2 u = f * f * (3.0 - 2.0 * f); + vec2 du = 6.0 * f * (1.0 - f); + float a = hash(p + vec2(0, 0)); + float b = hash(p + vec2(1, 0)); + float c = hash(p + vec2(0, 1)); + float d = hash(p + vec2(1, 1)); + return vec3( + a + (b - a) * u.x + (c - a) * u.y + (a - b - c + d) * u.x * u.y, + du * (vec2(b - a, c - a) + (a - b - c + d) * u.yx) + ); +} + +// Erosion FBM: higher gradient = lower contribution +float terrainFBM(in vec2 x) { + const mat2 m2 = mat2(0.8, -0.6, 0.6, 0.8); + float a = 0.0, b = 1.0; + vec2 d = vec2(0.0); + for (int i = 0; i < 16; i++) { + vec3 n = noised(x); + d += n.yz; + a += b * n.x / (1.0 + dot(d, d)); // 1/(1+|grad|^2) erosion factor + b *= 0.5; + x = m2 * x * 2.0; + } + return a; +} +``` + +### Quintic Noise with Analytical Derivatives + +C2-continuous noise using quintic interpolation — eliminates visible grid artifacts in derivatives: + +```glsl +// Returns vec3(value, dFdx, dFdy) — derivatives are exact, not finite-differenced +vec3 noisedQ(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + // Quintic interpolation for C2 continuity + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + vec2 du = 30.0 * f * f * (f * (f - 2.0) + 1.0); + + float a = hash12(i + vec2(0.0, 0.0)); + float b = hash12(i + vec2(1.0, 0.0)); + float c = hash12(i + vec2(0.0, 1.0)); + float d = hash12(i + vec2(1.0, 1.0)); + + float k0 = a, k1 = b - a, k2 = c - a, k3 = a - b - c + d; + return vec3( + k0 + k1 * u.x + k2 * u.y + k3 * u.x * u.y, // value + du * vec2(k1 + k3 * u.y, k2 + k3 * u.x) // derivatives + ); +} +``` + +### FBM with Derivatives (Erosion Terrain) + +Accumulates derivatives across octaves — derivative magnitude dampens amplitude, creating realistic erosion patterns: + +```glsl +vec3 fbmDerivative(vec2 p, int octaves) { + float value = 0.0; + vec2 deriv = vec2(0.0); + float amplitude = 0.5; + float frequency = 1.0; + mat2 rot = mat2(0.8, 0.6, -0.6, 0.8); // inter-octave rotation + + for (int i = 0; i < octaves; i++) { + vec3 n = noisedQ(p * frequency); + deriv += n.yz; + // Key: divide by (1 + dot(deriv, deriv)) for erosion effect + value += amplitude * n.x / (1.0 + dot(deriv, deriv)); + frequency *= 2.0; + amplitude *= 0.5; + p = rot * p; // rotate to break axis-aligned artifacts + } + return vec3(value, deriv); +} +``` + +Key insights: +- **Quintic interpolation**: `6t^5 - 15t^4 + 10t^3` gives C2 continuous noise (vs Hermite's C1), eliminating visible grid artifacts in derivatives +- **Erosion FBM**: The `1/(1+dot(d,d))` term causes flat areas to accumulate more detail while steep slopes stay smooth — mimicking real erosion +- **Inter-octave rotation**: The 2x2 rotation matrix between octaves prevents axis-aligned patterns especially visible in ridged noise + +### Voronoise (Voronoi-Noise Hybrid) + +Unified interpolation between value noise and Voronoi patterns: + +```glsl +// u=0: Value noise, u=1: Voronoi, v: smoothness (0=sharp cells, 1=smooth) +vec3 hash32(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973)); + p3 += dot(p3, p3.yxz + 33.33); + return fract((p3.xxy + p3.yzz) * p3.zyx); +} + +float voronoise(vec2 p, float u, float v) { + float k = 1.0 + 63.0 * pow(1.0 - v, 6.0); + vec2 i = floor(p); + vec2 f = fract(p); + vec2 a = vec2(0.0); + for (int y = -2; y <= 2; y++) + for (int x = -2; x <= 2; x++) { + vec2 g = vec2(float(x), float(y)); + vec3 o = hash32(i + g) * vec3(u, u, 1.0); + vec2 d = g - f + o.xy; + float w = pow(1.0 - smoothstep(0.0, 1.414, length(d)), k); + a += vec2(o.z * w, w); + } + return a.x / a.y; +} +``` + +Extremely versatile — smoothly interpolates between cellular Voronoi and continuous noise. + +### Preventing Aliasing in Procedural Textures + +For distant surfaces, high-frequency noise octaves create moiré artifacts. Solutions: + +1. **LOD-based octave count**: `int octaves = min(MAX_OCTAVES, int(log2(pixelSize)))` — skip octaves finer than pixel size +2. **Analytical filtering**: For simple patterns (checkers, stripes), use smoothstep with pixel width: `smoothstep(-fw, fw, pattern)` where `fw = fwidth(uv)` +3. **Derivative-based mip**: Use `textureGrad()` with manually computed ray differentials for texture lookups in ray-marched scenes (see texture-mapping-advanced technique) + +## Complete Code Template + +Ready to run in ShaderToy. Switch between standard FBM / ridged FBM / domain warping modes via `#define`: + +```glsl +// ============================================================ +// Procedural Noise Skill — Complete Template +// ============================================================ + +// ========== Mode selection (uncomment to switch) ========== +#define MODE_STANDARD_FBM // Standard FBM clouds +//#define MODE_RIDGED_FBM // Ridged FBM lightning texture +//#define MODE_DOMAIN_WARP // Domain warped organic pattern + +// ========== Tunable parameters ========== +#define OCTAVES 6 +#define GAIN 0.5 +#define LACUNARITY 2.0 +#define NOISE_SCALE 3.0 +#define ANIM_SPEED 0.3 +#define WARP_STRENGTH 0.4 + +// ========== Hash function ========== +float hash(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +// ========== Value noise ========== +float noise(in vec2 x) { + vec2 p = floor(x); + vec2 f = fract(x); + f = f * f * (3.0 - 2.0 * f); + float a = hash(p + vec2(0.0, 0.0)); + float b = hash(p + vec2(1.0, 0.0)); + float c = hash(p + vec2(0.0, 1.0)); + float d = hash(p + vec2(1.0, 1.0)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +// ========== Rotation+scale matrix ========== +const mat2 m = mat2(1.6, 1.2, -1.2, 1.6); + +// ========== Standard FBM ========== +float fbm(vec2 p) { + float f = 0.0, a = 0.5; + for (int i = 0; i < OCTAVES; i++) { + f += a * (-1.0 + 2.0 * noise(p)); + p = m * p; + a *= GAIN; + } + return f; +} + +// ========== Ridged FBM ========== +float fbm_ridged(vec2 p) { + float f = 0.0, a = 0.5; + for (int i = 0; i < OCTAVES; i++) { + f += a * abs(-1.0 + 2.0 * noise(p)); + p = m * p; + a *= GAIN; + } + return f; +} + +// ========== Domain warping vec2 FBM ========== +vec2 fbm2(vec2 p) { + return vec2(fbm(p + vec2(1.7, 9.2)), fbm(p + vec2(8.3, 2.8))); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; + uv *= NOISE_SCALE; + float time = iTime * ANIM_SPEED; + float f = 0.0; + vec3 col = vec3(0.0); + +#ifdef MODE_STANDARD_FBM + f = 0.5 + 0.5 * fbm(uv + vec2(0.0, -time)); + vec3 sky = mix(vec3(0.4, 0.7, 1.0), vec3(0.2, 0.4, 0.6), fragCoord.y / iResolution.y); + vec3 cloud = vec3(1.1, 1.1, 0.9) * f; + col = mix(sky, cloud, smoothstep(0.4, 0.7, f)); +#endif + +#ifdef MODE_RIDGED_FBM + f = fbm_ridged(uv + vec2(time * 0.5, time * 0.3)); + col = vec3(0.2, 0.1, 0.4) / max(f, 0.05); + col = pow(col, vec3(0.99)); +#endif + +#ifdef MODE_DOMAIN_WARP + vec2 q = fbm2(uv + time * 0.1); + vec2 r = fbm2(uv + WARP_STRENGTH * q + vec2(1.7, 9.2)); + f = 0.5 + 0.5 * fbm(uv + WARP_STRENGTH * r); + f = mix(f, f * f * f * 3.5, f * length(r)); + col = vec3(0.2, 0.1, 0.4); + col = mix(col, vec3(0.3, 0.05, 0.05), f); + col = mix(col, vec3(0.9, 0.9, 0.9), dot(r, r)); + col = mix(col, vec3(0.5, 0.2, 0.2), 0.5 * q.y * q.y); + col *= f * 2.0; + vec2 eps = vec2(1.0 / iResolution.x, 0.0); + float fx = 0.5 + 0.5 * fbm(uv + eps.xy + WARP_STRENGTH * fbm2(uv + eps.xy + time * 0.1)); + float fy = 0.5 + 0.5 * fbm(uv + eps.yx + WARP_STRENGTH * fbm2(uv + eps.yx + time * 0.1)); + vec3 nor = normalize(vec3(fx - f, eps.x, fy - f)); + vec3 lig = normalize(vec3(0.9, -0.2, -0.4)); + float dif = clamp(0.3 + 0.7 * dot(nor, lig), 0.0, 1.0); + col *= vec3(0.85, 0.90, 0.95) * (nor.y * 0.5 + 0.5) + vec3(0.15, 0.10, 0.05) * dif; +#endif + + vec2 p = fragCoord / iResolution.xy; + col *= 0.5 + 0.5 * sqrt(16.0 * p.x * p.y * (1.0 - p.x) * (1.0 - p.y)); + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Ridged FBM + +```glsl +f += a * abs(noise(p)); // V-shaped ridges +f += a * (sin(noise(p)*7.0)*0.5+0.5); // Sinusoidal ridges (lava) +``` + +### Domain Warped FBM + +```glsl +vec2 o = 0.5 + 0.5 * vec2(fbm(q + vec2(1.0)), fbm(q + vec2(6.2))); +vec2 n = vec2(fbm(4.0 * o + vec2(9.2)), fbm(4.0 * o + vec2(5.7))); +float f = 0.5 + 0.5 * fbm(q + 2.0 * n + 1.0); +``` + +### Derivative Erosion FBM + +```glsl +vec2 d = vec2(0.0); +for (int i = 0; i < N; i++) { + vec3 n = noised(p); + d += n.yz; + a += b * n.x / (1.0 + dot(d, d)); + b *= 0.5; p = m2 * p * 2.0; +} +``` + +### Fluid Noise + +```glsl +for (float i = 1.0; i < 7.0; i++) { + vec2 gr = gradn(i * p * 0.34 + time); + gr *= makem2(time * 6.0 - (0.05*p.x+0.03*p.y)*40.0); + p += gr * 0.5; + rz += (sin(noise(p)*7.0)*0.5+0.5) / z; + p = mix(bp, p, 0.77); +} +``` + +### Ocean Wave Octave Function + +```glsl +float sea_octave(vec2 uv, float choppy) { + uv += noise(uv); + vec2 wv = 1.0 - abs(sin(uv)); + vec2 swv = abs(cos(uv)); + wv = mix(wv, swv, wv); + return pow(1.0 - pow(wv.x * wv.y, 0.65), choppy); +} +// Bidirectional propagation in FBM: +d = sea_octave((uv + SEA_TIME) * freq, choppy); +d += sea_octave((uv - SEA_TIME) * freq, choppy); +choppy = mix(choppy, 1.0, 0.2); +``` + +## Performance & Composition + +**Performance optimization:** +- Reducing octave count is the most direct optimization; use fewer octaves for distant objects: `int oct = 5 - int(log2(1.0 + t * 0.5));` +- Multi-level LOD: `terrainL` (3 oct) / `terrainM` (9 oct) / `terrainH` (16 oct) +- Texture sampling instead of math hash: `texture(iChannel0, x * 0.01).x` +- Manually unroll small loops + slightly vary lacunarity +- Adaptive step size: `float dt = max(0.05, 0.02 * t);` +- Directional derivative instead of full gradient (1 sample vs 3) +- Early termination: `if (sum.a > 0.99) break;` + +**Common combinations:** +- FBM + Raymarching: noise-driven height/density fields, ray marching for intersection (terrain/ocean) +- FBM + finite-difference normals + lighting: `nor = normalize(vec3(f(p+ex)-f(p), eps, f(p+ey)-f(p)))` +- FBM + color mapping: different power curves mapping to RGB, e.g. flame `vec3(1.5*c, 1.5*c^3, c^6)` or inverse `vec3(k)/rz` +- FBM + Fresnel water surface: `fresnel = pow(1.0 - dot(n, -eye), 3.0)` +- Multi-layer FBM compositing: shape layer (low freq) + ridged layer (mid freq) + color layer (high freq) +- FBM + volumetric lighting: density difference along light direction approximates illumination + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/procedural-noise.md) diff --git a/skills/shader-dev/techniques/ray-marching.md b/skills/shader-dev/techniques/ray-marching.md new file mode 100644 index 0000000..60cfec3 --- /dev/null +++ b/skills/shader-dev/techniques/ray-marching.md @@ -0,0 +1,467 @@ +# Ray Marching + +## Use Cases + +- Rendering implicit surfaces (geometry defined by mathematical functions) without triangle meshes +- Creating fractals, organic forms, liquid metal, and other shapes difficult to express with traditional modeling +- Implementing volumetric effects: fire, smoke, clouds, glow +- Rapid prototyping of procedural scenes: building complex scenes by combining SDF primitives with boolean operations +- Advanced distance-field-based lighting: soft shadows, ambient occlusion, subsurface scattering + +## Core Principles + +Cast a ray from the camera along each pixel direction, advancing step by step using a **Signed Distance Function (SDF)** (Sphere Tracing). Each step advances by the SDF value at the current point, guaranteeing no surface penetration. + +- Ray equation: `P(t) = ro + t * rd` +- Stepping logic: `t += SDF(P(t))` +- Hit test: `SDF(P) < epsilon` +- Normal estimation: `N = normalize(gradient of SDF(P))` (direction of the SDF gradient) +- Volumetric rendering: advance at fixed step size, accumulating density and color per step (front-to-back compositing) + +## Implementation Steps + +### Step 1: UV Normalization and Ray Direction + +```glsl +// Concise version +vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; +vec3 ro = vec3(0.0, 0.0, -3.0); +vec3 rd = normalize(vec3(uv, 1.0)); // z=1.0 ~ 90 deg FOV + +// Precise FOV control +vec2 xy = fragCoord - iResolution.xy / 2.0; +float z = iResolution.y / tan(radians(FOV) / 2.0); +vec3 rd = normalize(vec3(xy, -z)); +``` + +### Step 2: Camera Matrix (Look-At) + +```glsl +mat3 setCamera(vec3 ro, vec3 ta, float cr) { + vec3 cw = normalize(ta - ro); + vec3 cp = vec3(sin(cr), cos(cr), 0.0); + vec3 cu = normalize(cross(cw, cp)); + vec3 cv = cross(cu, cw); + return mat3(cu, cv, cw); +} + +mat3 ca = setCamera(ro, ta, 0.0); +vec3 rd = ca * normalize(vec3(uv, FOCAL_LENGTH)); // 1.0~3.0, larger = narrower FOV +``` + +### Step 3: Scene SDF + +```glsl +// SDF primitives +float sdSphere(vec3 p, float r) { return length(p) - r; } + +float sdBox(vec3 p, vec3 b) { + vec3 d = abs(p) - b; + return min(max(d.x, max(d.y, d.z)), 0.0) + length(max(d, 0.0)); +} + +float sdTorus(vec3 p, vec2 t) { + return length(vec2(length(p.xz) - t.x, p.y)) - t.y; +} + +// Boolean operations +float opUnion(float a, float b) { return min(a, b); } +float opSubtraction(float a, float b) { return max(a, -b); } +float opIntersection(float a, float b) { return max(a, b); } + +// Smooth blending, adjustable k: 0.1~0.5 +float smin(float a, float b, float k) { + float h = max(k - abs(a - b), 0.0); + return min(a, b) - h * h * 0.25 / k; +} + +// Scene composition +float map(vec3 p) { + float d = sdSphere(p - vec3(0.0, 0.5, 0.0), 0.5); + d = opUnion(d, p.y); // ground + d = smin(d, sdBox(p - vec3(1.0, 0.3, 0.0), vec3(0.3)), 0.2); // smooth blend with box + return d; +} +``` + +### Step 4: Ray Marching Loop + +```glsl +#define MAX_STEPS 128 +#define MAX_DIST 100.0 +#define SURF_DIST 0.001 + +float rayMarch(vec3 ro, vec3 rd) { + float t = 0.0; + for (int i = 0; i < MAX_STEPS; i++) { + vec3 p = ro + t * rd; + float d = map(p); + if (d < SURF_DIST) return t; + t += d; + if (t > MAX_DIST) break; + } + return -1.0; +} +``` + +### Step 5: Normal Estimation + +```glsl +// Central differences (6 SDF evaluations) +vec3 calcNormal(vec3 p) { + vec2 e = vec2(0.001, 0.0); + return normalize(vec3( + map(p + e.xyy) - map(p - e.xyy), + map(p + e.yxy) - map(p - e.yxy), + map(p + e.yyx) - map(p - e.yyx) + )); +} + +// Tetrahedral trick (4 SDF evaluations, recommended) +vec3 calcNormal(vec3 pos) { + vec3 n = vec3(0.0); + for (int i = 0; i < 4; i++) { + vec3 e = 0.5773 * (2.0 * vec3((((i+3)>>1)&1), ((i>>1)&1), (i&1)) - 1.0); + n += e * map(pos + 0.001 * e); + } + return normalize(n); +} +``` + +### Step 6: Lighting and Shading + +```glsl +vec3 shade(vec3 p, vec3 rd) { + vec3 nor = calcNormal(p); + vec3 lightDir = normalize(vec3(0.6, 0.35, 0.5)); + vec3 halfDir = normalize(lightDir - rd); + + float diff = clamp(dot(nor, lightDir), 0.0, 1.0); + float spec = pow(clamp(dot(nor, halfDir), 0.0, 1.0), SHININESS); // 8~64 + float sky = sqrt(clamp(0.5 + 0.5 * nor.y, 0.0, 1.0)); + + vec3 col = vec3(0.2, 0.2, 0.25); + vec3 lin = vec3(0.0); + lin += diff * vec3(1.3, 1.0, 0.7) * 2.2; + lin += sky * vec3(0.4, 0.6, 1.15) * 0.6; + lin += vec3(0.25) * 0.55; + col *= lin; + col += spec * vec3(1.3, 1.0, 0.7) * 5.0; + return col; +} +``` + +### Step 7: Post-Processing + +```glsl +col = pow(col, vec3(0.4545)); // Gamma correction (1/2.2) +col = col / (1.0 + col); // Reinhard tone mapping (optional, before gamma) + +// Vignette (optional) +vec2 q = fragCoord / iResolution.xy; +col *= 0.5 + 0.5 * pow(16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y), 0.25); +``` + +## Full Code Template + +Can be pasted directly into ShaderToy. Includes SDF scene, Phong lighting, soft shadows, and ambient occlusion: + +```glsl +// ============================================================ +// Ray Marching Full Template — ShaderToy +// ============================================================ + +#define MAX_STEPS 128 +#define MAX_DIST 100.0 +#define SURF_DIST 0.001 +#define SHADOW_STEPS 24 +#define AO_STEPS 5 +#define FOCAL_LENGTH 2.5 +#define SHININESS 16.0 + +// --- SDF Primitives --- +float sdSphere(vec3 p, float r) { return length(p) - r; } + +float sdBox(vec3 p, vec3 b) { + vec3 d = abs(p) - b; + return min(max(d.x, max(d.y, d.z)), 0.0) + length(max(d, 0.0)); +} + +float sdTorus(vec3 p, vec2 t) { + return length(vec2(length(p.xz) - t.x, p.y)) - t.y; +} + +// --- Boolean Operations --- +float opUnion(float a, float b) { return min(a, b); } +float opSubtraction(float a, float b) { return max(a, -b); } +float opIntersection(float a, float b) { return max(a, b); } + +float smin(float a, float b, float k) { + float h = max(k - abs(a - b), 0.0); + return min(a, b) - h * h * 0.25 / k; +} + +mat2 rot2D(float a) { + float c = cos(a), s = sin(a); + return mat2(c, -s, s, c); +} + +// --- Scene Definition --- +float map(vec3 p) { + float ground = p.y; + vec3 q = p - vec3(0.0, 0.8, 0.0); + q.xz *= rot2D(iTime * 0.5); + float body = smin(sdSphere(q, 0.5), sdTorus(q, vec2(0.8, 0.15)), 0.3); + return opUnion(ground, body); +} + +// --- Normal (Tetrahedral Trick) --- +vec3 calcNormal(vec3 pos) { + vec3 n = vec3(0.0); + for (int i = min(iFrame,0); i < 4; i++) { + vec3 e = 0.5773 * (2.0 * vec3((((i+3)>>1)&1), ((i>>1)&1), (i&1)) - 1.0); + n += e * map(pos + 0.001 * e); + } + return normalize(n); +} + +// --- Soft Shadows --- +float calcSoftShadow(vec3 ro, vec3 rd, float tmin, float tmax) { + float res = 1.0, t = tmin; + for (int i = 0; i < SHADOW_STEPS; i++) { + float h = map(ro + rd * t); + float s = clamp(8.0 * h / t, 0.0, 1.0); + res = min(res, s); + t += clamp(h, 0.01, 0.2); + if (res < 0.004 || t > tmax) break; + } + res = clamp(res, 0.0, 1.0); + return res * res * (3.0 - 2.0 * res); +} + +// --- Ambient Occlusion --- +float calcAO(vec3 pos, vec3 nor) { + float occ = 0.0, sca = 1.0; + for (int i = 0; i < AO_STEPS; i++) { + float h = 0.01 + 0.12 * float(i) / float(AO_STEPS - 1); + float d = map(pos + h * nor); + occ += (h - d) * sca; + sca *= 0.95; + } + return clamp(1.0 - 3.0 * occ, 0.0, 1.0); +} + +// --- Ray March --- +float rayMarch(vec3 ro, vec3 rd) { + float t = 0.0; + for (int i = 0; i < MAX_STEPS; i++) { + vec3 p = ro + t * rd; + float d = map(p); + if (abs(d) < SURF_DIST * (1.0 + t * 0.1)) return t; + t += d; + if (t > MAX_DIST) break; + } + return -1.0; +} + +// --- Camera --- +mat3 setCamera(vec3 ro, vec3 ta, float cr) { + vec3 cw = normalize(ta - ro); + vec3 cp = vec3(sin(cr), cos(cr), 0.0); + vec3 cu = normalize(cross(cw, cp)); + vec3 cv = cross(cu, cw); + return mat3(cu, cv, cw); +} + +// --- Rendering --- +vec3 render(vec3 ro, vec3 rd) { + vec3 col = vec3(0.7, 0.7, 0.9) - max(rd.y, 0.0) * 0.3; // sky + + float t = rayMarch(ro, rd); + if (t > 0.0) { + vec3 pos = ro + t * rd; + vec3 nor = calcNormal(pos); + + // Material + vec3 mate = vec3(0.18); + if (pos.y < 0.001) { + float f = mod(floor(pos.x) + floor(pos.z), 2.0); + mate = vec3(0.1 + 0.05 * f); + } else { + mate = 0.2 + 0.2 * sin(vec3(0.0, 1.0, 2.0)); + } + + // Lighting + vec3 lightDir = normalize(vec3(-0.5, 0.4, -0.6)); + float occ = calcAO(pos, nor); + float dif = clamp(dot(nor, lightDir), 0.0, 1.0); + dif *= calcSoftShadow(pos + nor * 0.01, lightDir, 0.02, 2.5); + vec3 hal = normalize(lightDir - rd); + float spe = pow(clamp(dot(nor, hal), 0.0, 1.0), SHININESS) * dif; + float sky = sqrt(clamp(0.5 + 0.5 * nor.y, 0.0, 1.0)); + + vec3 lin = vec3(0.0); + lin += dif * vec3(1.3, 1.0, 0.7) * 2.2; + lin += sky * vec3(0.4, 0.6, 1.15) * 0.6 * occ; + lin += vec3(0.25) * 0.55 * occ; + col = mate * lin; + col += spe * vec3(1.3, 1.0, 0.7) * 5.0; + + col = mix(col, vec3(0.7, 0.7, 0.9), 1.0 - exp(-0.0001 * t * t * t)); // distance fog + } + return clamp(col, 0.0, 1.0); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + float time = 32.0 + iTime * 1.5; + vec2 mo = iMouse.xy / iResolution.xy; + vec3 ta = vec3(0.0, 0.5, 0.0); + vec3 ro = ta + vec3(4.0*cos(0.1*time+7.0*mo.x), 1.5, 4.0*sin(0.1*time+7.0*mo.x)); + mat3 ca = setCamera(ro, ta, 0.0); + + vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; + vec3 rd = ca * normalize(vec3(uv, FOCAL_LENGTH)); + + vec3 col = render(ro, rd); + col = pow(col, vec3(0.4545)); + + vec2 q = fragCoord / iResolution.xy; + col *= 0.5 + 0.5 * pow(16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y), 0.25); + + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### 1. Volumetric Ray Marching + +Advance at fixed step size, accumulating density/color per step. Used for fire, smoke, and clouds. + +```glsl +#define VOL_STEPS 150 +#define VOL_STEP_SIZE 0.05 + +float fbmDensity(vec3 p) { + float den = 0.2 - p.y; + vec3 q = p - vec3(0.0, 1.0, 0.0) * iTime; + float f = 0.5000 * noise(q); q = q * 2.02 - vec3(0.0, 1.0, 0.0) * iTime; + f += 0.2500 * noise(q); q = q * 2.03 - vec3(0.0, 1.0, 0.0) * iTime; + f += 0.1250 * noise(q); q = q * 2.01 - vec3(0.0, 1.0, 0.0) * iTime; + f += 0.0625 * noise(q); + return den + 4.0 * f; +} + +vec3 volumetricMarch(vec3 ro, vec3 rd) { + vec4 sum = vec4(0.0); + float t = 0.05; + for (int i = 0; i < VOL_STEPS; i++) { + vec3 pos = ro + t * rd; + float den = fbmDensity(pos); + if (den > 0.0) { + den = min(den, 1.0); + vec3 col = mix(vec3(1.0,0.5,0.05), vec3(0.48,0.53,0.5), clamp(pos.y*0.5,0.0,1.0)); + col *= den; col.a = den * 0.6; col.rgb *= col.a; + sum += col * (1.0 - sum.a); + if (sum.a > 0.99) break; + } + t += VOL_STEP_SIZE; + } + return clamp(sum.rgb, 0.0, 1.0); +} +``` + +### 2. CSG Scene Construction + +```glsl +float sceneSDF(vec3 p) { + p = rotateY(iTime * 0.5) * p; + float sphere = sdSphere(p, 1.2); + float cube = sdBox(p, vec3(0.9)); + float cyl = sdCylinder(p, vec2(0.4, 2.0)); + float cylX = sdCylinder(p.yzx, vec2(0.4, 2.0)); + float cylZ = sdCylinder(p.xzy, vec2(0.4, 2.0)); + return opSubtraction(opIntersection(sphere, cube), opUnion(cyl, opUnion(cylX, cylZ))); +} +``` + +### 3. Physically-Based Volumetric Scattering + +```glsl +void getParticipatingMedia(out float sigmaS, out float sigmaE, vec3 pos) { + float heightFog = 0.3 * clamp((7.0 - pos.y), 0.0, 1.0); + sigmaS = 0.02 + heightFog; + sigmaE = max(0.000001, sigmaS); +} + +vec3 S = lightColor * sigmaS * phaseFunction() * volShadow; +vec3 Sint = (S - S * exp(-sigmaE * stepLen)) / sigmaE; +scatteredLight += transmittance * Sint; +transmittance *= exp(-sigmaE * stepLen); +``` + +### 4. Glow Accumulation + +```glsl +vec2 rayMarchWithGlow(vec3 ro, vec3 rd) { + float t = 0.0, dMin = MAX_DIST; + for (int i = 0; i < MAX_STEPS; i++) { + vec3 p = ro + t * rd; + float d = map(p); + if (d < dMin) dMin = d; + if (d < SURF_DIST) break; + t += d; + if (t > MAX_DIST) break; + } + return vec2(t, dMin); +} + +float glow = 0.02 / max(dMin, 0.001); +col += glow * vec3(1.0, 0.8, 0.9); +``` + +### 5. Refraction and Bidirectional Marching + +```glsl +float castRay(vec3 ro, vec3 rd) { + float sign = (map(ro) < 0.0) ? -1.0 : 1.0; + float t = 0.0; + for (int i = 0; i < 120; i++) { + float h = sign * map(ro + rd * t); + if (abs(h) < 0.0001 || t > 12.0) break; + t += h; + } + return t; +} + +vec3 refDir = refract(rd, nor, IOR); // IOR: index of refraction, e.g. 0.9 +float t2 = 2.0; +for (int i = 0; i < 50; i++) { + float h = map(hitPos + refDir * t2); + t2 -= h; + if (abs(h) > 3.0) break; +} +vec3 nor2 = calcNormal(hitPos + refDir * t2); +``` + +## Performance & Composition + +**Performance tips:** +- Use tetrahedral trick for normals (4 SDF evaluations instead of 6) +- `min(iFrame,0)` as loop start value to prevent compiler unrolling +- AABB bounding box pre-test to skip empty regions +- Adaptive hit threshold: `SURF_DIST * (1.0 + t * 0.1)` +- Step clamping: `t += clamp(h, 0.01, 0.2)` +- Early exit for volumetric rendering when `sum.a > 0.99` +- Use cheap bounding SDF first, then compute precise SDF + +**Composition directions:** +- + FBM noise: terrain/rock texture, cloud/smoke volumetric density fields +- + Domain transforms (twist/bend/repeat): infinite repeating corridors, surreal geometry +- + PBR materials (Cook-Torrance BRDF + Fresnel + environment mapping) +- + Multi-pass post-processing: depth of field, motion blur, tone mapping +- + Procedural animation: time-driven SDF parameters + smoothstep easing + +## Further Reading + +Full step-by-step tutorials, mathematical derivations, and advanced usage in [reference](../reference/ray-marching.md) diff --git a/skills/shader-dev/techniques/sdf-2d.md b/skills/shader-dev/techniques/sdf-2d.md new file mode 100644 index 0000000..10bb338 --- /dev/null +++ b/skills/shader-dev/techniques/sdf-2d.md @@ -0,0 +1,631 @@ +# 2D SDF Rendering Skill + +## Use Cases + +- 2D shape rendering: circles, rectangles, triangles, ellipses, line segments, Bezier curves, etc. +- UI elements and icons: drawn with math functions, naturally resolution-independent +- Anti-aliased graphics, shape boolean operations, outlines and glow +- Motion graphics and animation, 2D soft shadows and lighting + +## Core Principles + +For each pixel, compute the signed distance `d` to the shape boundary: `d < 0` inside, `d = 0` boundary, `d > 0` outside. + +Map to color via `smoothstep`/`clamp`: +- **Fill**: color when `d < 0` +- **Anti-aliasing**: `smoothstep(-aa, aa, d)` +- **Stroke**: apply smoothstep to `abs(d) - strokeWidth` +- **Boolean operations**: `min(d1, d2)` union, `max(d1, d2)` intersection, `max(-d1, d2)` subtraction + +Key formulas: +``` +Circle: d = length(p - center) - radius +Rectangle: d = length(max(abs(p) - halfSize, 0.0)) + min(max(abs(p).x - halfSize.x, abs(p).y - halfSize.y), 0.0) +Line segment: d = length(p - a - clamp(dot(p-a, b-a)/dot(b-a, b-a), 0, 1) * (b-a)) - width/2 +Smooth union: d = mix(d2, d1, h) - k*h*(1-h), h = clamp(0.5 + 0.5*(d2-d1)/k, 0, 1) +``` + +## Implementation Steps + +### Step 1: Coordinate Normalization + +```glsl +// Origin at center, y range [-1, 1] (standard approach) +vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; + +// Pixel space (suitable for fixed pixel-size UI) +vec2 p = fragCoord.xy; +vec2 center = iResolution.xy * 0.5; + +// [0, 1] range (requires manual aspect ratio handling) +vec2 uv = fragCoord.xy / iResolution.xy; +``` + +### Step 2: SDF Primitive Functions + +```glsl +float sdCircle(vec2 p, float radius) { + return length(p) - radius; +} + +// halfSize is half-width/half-height, radius is corner rounding +float sdBox(vec2 p, vec2 halfSize, float radius) { + halfSize -= vec2(radius); + vec2 d = abs(p) - halfSize; + return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)) - radius; +} + +float sdLine(vec2 p, vec2 start, vec2 end, float width) { + vec2 dir = end - start; + float h = clamp(dot(p - start, dir) / dot(dir, dir), 0.0, 1.0); + return length(p - start - dir * h) - width * 0.5; +} + +// Exact signed distance, requires only one sqrt +float sdTriangle(vec2 p, vec2 p0, vec2 p1, vec2 p2) { + vec2 e0 = p1 - p0, v0 = p - p0; + vec2 e1 = p2 - p1, v1 = p - p1; + vec2 e2 = p0 - p2, v2 = p - p2; + float d0 = dot(v0 - e0 * clamp(dot(v0, e0) / dot(e0, e0), 0.0, 1.0), + v0 - e0 * clamp(dot(v0, e0) / dot(e0, e0), 0.0, 1.0)); + float d1 = dot(v1 - e1 * clamp(dot(v1, e1) / dot(e1, e1), 0.0, 1.0), + v1 - e1 * clamp(dot(v1, e1) / dot(e1, e1), 0.0, 1.0)); + float d2 = dot(v2 - e2 * clamp(dot(v2, e2) / dot(e2, e2), 0.0, 1.0), + v2 - e2 * clamp(dot(v2, e2) / dot(e2, e2), 0.0, 1.0)); + float o = e0.x * e2.y - e0.y * e2.x; + vec2 d = min(min(vec2(d0, o * (v0.x * e0.y - v0.y * e0.x)), + vec2(d1, o * (v1.x * e1.y - v1.y * e1.x))), + vec2(d2, o * (v2.x * e2.y - v2.y * e2.x))); + return -sqrt(d.x) * sign(d.y); +} + +// Approximate ellipse SDF +float sdEllipse(vec2 p, vec2 center, float a, float b) { + float a2 = a * a, b2 = b * b; + vec2 d = p - center; + return (b2 * d.x * d.x + a2 * d.y * d.y - a2 * b2) / (a2 * b2); +} +``` + +### Step 3: CSG Boolean Operations + +```glsl +float opUnion(float d1, float d2) { return min(d1, d2); } +float opIntersect(float d1, float d2) { return max(d1, d2); } +float opSubtract(float d1, float d2) { return max(-d1, d2); } +float opXor(float d1, float d2) { return min(max(-d1, d2), max(-d2, d1)); } + +// k controls transition width +float opSmoothUnion(float d1, float d2, float k) { + float h = clamp(0.5 + 0.5 * (d2 - d1) / k, 0.0, 1.0); + return mix(d2, d1, h) - k * h * (1.0 - h); +} +``` + +### Step 4: Coordinate Transforms + +```glsl +vec2 translate(vec2 p, vec2 t) { return p - t; } + +vec2 rotateCCW(vec2 p, float angle) { + mat2 m = mat2(cos(angle), sin(angle), -sin(angle), cos(angle)); + return p * m; +} + +// Usage: translate first, then rotate +float d = sdBox(rotateCCW(translate(p, vec2(0.5, 0.3)), iTime), vec2(0.2), 0.05); +``` + +### Step 5: Rendering and Anti-Aliasing + +```glsl +// smoothstep anti-aliasing (recommended) +float px = 2.0 / iResolution.y; +float mask = smoothstep(px, -px, d); // 1.0 inside, 0.0 outside +vec3 col = mix(backgroundColor, shapeColor, mask); + +// fwidth adaptive anti-aliasing (suitable for scaled scenes) +float anti = fwidth(d) * 1.0; +float mask = 1.0 - smoothstep(-anti, anti, d); + +// Classic distance field debug visualization +vec3 col = (d > 0.0) ? vec3(0.9, 0.6, 0.3) : vec3(0.65, 0.85, 1.0); +col *= 1.0 - exp(-12.0 * abs(d)); +col *= 0.8 + 0.2 * cos(120.0 * d); +col = mix(col, vec3(1.0), smoothstep(1.5*px, 0.0, abs(d) - 0.002)); +``` + +### Step 6: Stroke and Border + +```glsl +// Fill + stroke rendering (fwidth adaptive) +vec4 renderShape(float d, vec3 color, float stroke) { + float anti = fwidth(d) * 1.0; + vec4 strokeLayer = vec4(vec3(0.05), 1.0 - smoothstep(-anti, anti, d - stroke)); + vec4 colorLayer = vec4(color, 1.0 - smoothstep(-anti, anti, d)); + if (stroke < 0.0001) return colorLayer; + return vec4(mix(strokeLayer.rgb, colorLayer.rgb, colorLayer.a), strokeLayer.a); +} + +float fillMask(float d) { return clamp(-d, 0.0, 1.0); } +float innerBorderMask(float d, float width) { + return clamp(d + width, 0.0, 1.0) - clamp(d, 0.0, 1.0); +} +float outerBorderMask(float d, float width) { + return clamp(d, 0.0, 1.0) - clamp(d - width, 0.0, 1.0); +} +``` + +### Step 7: Multi-Layer Compositing + +```glsl +vec3 bgColor = vec3(1.0, 0.8, 0.7 - 0.07 * p.y) * (1.0 - 0.25 * length(p)); + +float d1 = sdCircle(translate(p, pos1), 0.3); +vec4 layer1 = renderShape(d1, vec3(0.9, 0.3, 0.2), 0.02); + +float d2 = sdBox(translate(p, pos2), vec2(0.2), 0.05); +vec4 layer2 = renderShape(d2, vec3(0.2, 0.5, 0.8), 0.0); + +// Composite back to front +vec3 col = bgColor; +col = mix(col, layer1.rgb, layer1.a); +col = mix(col, layer2.rgb, layer2.a); +fragColor = vec4(col, 1.0); +``` + +## Full Code Template + +```glsl +// ===== 2D SDF Full Template (runs directly in ShaderToy) ===== + +#define AA_WIDTH 1.0 // Anti-aliasing width factor +#define STROKE_WIDTH 0.015 // Stroke width +#define SMOOTH_K 0.05 // Smooth union transition width +#define CONTOUR_FREQ 80.0 // Contour line frequency (for debugging) +#define ANIM_SPEED 1.0 // Animation speed multiplier + +// --- SDF Primitives --- +float sdCircle(vec2 p, float r) { return length(p) - r; } + +float sdBox(vec2 p, vec2 b, float r) { + b -= vec2(r); + vec2 d = abs(p) - b; + return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)) - r; +} + +float sdLine(vec2 p, vec2 a, vec2 b, float w) { + vec2 d = b - a; + float h = clamp(dot(p - a, d) / dot(d, d), 0.0, 1.0); + return length(p - a - d * h) - w * 0.5; +} + +float sdTriangle(vec2 p, vec2 p0, vec2 p1, vec2 p2) { + vec2 e0 = p1 - p0, v0 = p - p0; + vec2 e1 = p2 - p1, v1 = p - p1; + vec2 e2 = p0 - p2, v2 = p - p2; + float d0 = dot(v0 - e0 * clamp(dot(v0,e0)/dot(e0,e0),0.0,1.0), + v0 - e0 * clamp(dot(v0,e0)/dot(e0,e0),0.0,1.0)); + float d1 = dot(v1 - e1 * clamp(dot(v1,e1)/dot(e1,e1),0.0,1.0), + v1 - e1 * clamp(dot(v1,e1)/dot(e1,e1),0.0,1.0)); + float d2 = dot(v2 - e2 * clamp(dot(v2,e2)/dot(e2,e2),0.0,1.0), + v2 - e2 * clamp(dot(v2,e2)/dot(e2,e2),0.0,1.0)); + float o = e0.x*e2.y - e0.y*e2.x; + vec2 dd = min(min(vec2(d0, o*(v0.x*e0.y-v0.y*e0.x)), + vec2(d1, o*(v1.x*e1.y-v1.y*e1.x))), + vec2(d2, o*(v2.x*e2.y-v2.y*e2.x))); + return -sqrt(dd.x) * sign(dd.y); +} + +// --- CSG --- +float opUnion(float a, float b) { return min(a, b); } +float opSubtract(float a, float b) { return max(-a, b); } +float opIntersect(float a, float b) { return max(a, b); } +float opSmoothUnion(float a, float b, float k) { + float h = clamp(0.5 + 0.5*(b - a)/k, 0.0, 1.0); + return mix(b, a, h) - k*h*(1.0-h); +} +float opXor(float a, float b) { return min(max(-a, b), max(-b, a)); } + +// --- Coordinate Transforms --- +vec2 translate(vec2 p, vec2 t) { return p - t; } +vec2 rotateCCW(vec2 p, float a) { + return mat2(cos(a), sin(a), -sin(a), cos(a)) * p; +} + +// --- Rendering Utilities --- +vec4 render(float d, vec3 color, float stroke) { + float anti = fwidth(d) * AA_WIDTH; + vec4 strokeLayer = vec4(vec3(0.05), 1.0 - smoothstep(-anti, anti, d - stroke)); + vec4 colorLayer = vec4(color, 1.0 - smoothstep(-anti, anti, d)); + if (stroke < 0.0001) return colorLayer; + return vec4(mix(strokeLayer.rgb, colorLayer.rgb, colorLayer.a), strokeLayer.a); +} + +float fillAA(float d, float px) { return smoothstep(px, -px, d); } + +// --- Scene --- +float sceneDist(vec2 p) { + float t = iTime * ANIM_SPEED; + float c = sdCircle(translate(p, vec2(-0.6, 0.3)), 0.25); + float b = sdBox(translate(p, vec2(0.0, 0.3)), vec2(0.25, 0.18), 0.05); + vec2 tp = rotateCCW(translate(p, vec2(0.6, 0.3)), t * 0.5); + float tr = sdTriangle(tp, vec2(0.0, 0.25), vec2(-0.22, -0.12), vec2(0.22, -0.12)); + float row1 = opUnion(c, opUnion(b, tr)); + + float c2 = sdCircle(translate(p, vec2(-0.5, -0.35)), 0.2); + float b2 = sdBox(translate(p, vec2(-0.3, -0.35)), vec2(0.15, 0.15), 0.0); + float smooth_demo = opSmoothUnion(c2, b2, SMOOTH_K); + + float c3 = sdCircle(translate(p, vec2(0.15, -0.35)), 0.22); + float b3 = sdBox(translate(p, vec2(0.15, -0.35 + sin(t) * 0.15)), vec2(0.3, 0.08), 0.0); + float sub_demo = opSubtract(b3, c3); + + float c4 = sdCircle(translate(p, vec2(0.65, -0.35)), 0.2); + float b4 = sdBox(translate(p, vec2(0.65, -0.35 + sin(t + 1.0) * 0.15)), vec2(0.3, 0.08), 0.0); + float xor_demo = opXor(b4, c4); + + float row2 = opUnion(smooth_demo, opUnion(sub_demo, xor_demo)); + return opUnion(row1, row2); +} + +// --- Main Function --- +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; + float px = 2.0 / iResolution.y; + float d = sceneDist(p); + + vec3 bgCol = vec3(0.15, 0.15, 0.18) + 0.05 * p.y; + bgCol *= 1.0 - 0.3 * length(p); + + vec3 col = (d > 0.0) ? vec3(0.9, 0.6, 0.3) : vec3(0.4, 0.7, 1.0); + col *= 1.0 - exp(-10.0 * abs(d)); + col *= 0.8 + 0.2 * cos(CONTOUR_FREQ * d); + col = mix(col, vec3(1.0), smoothstep(1.5 * px, 0.0, abs(d) - 0.002)); + col = mix(bgCol, col, 0.85); + + // Uncomment to switch to solid rendering mode: + // vec3 shapeCol = vec3(0.2, 0.8, 0.6); + // float mask = fillAA(d, px); + // col = mix(bgCol, shapeCol, mask); + + col = pow(col, vec3(1.0 / 2.2)); + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Variant 1: Solid Fill + Stroke Mode + +```glsl +vec3 shapeColor = vec3(0.32, 0.56, 0.53); +float strokeW = 0.015; +vec4 shape = render(d, shapeColor, strokeW); +vec3 col = bgCol; +col = mix(col, shape.rgb, shape.a); +``` + +### Variant 2: Multi-Layer CSG Illustration + +```glsl +float a = sdEllipse(p, vec2(0.0, 0.16), 0.25, 0.25); +float b = sdEllipse(p, vec2(0.0, -0.03), 0.8, 0.35); +float body = opIntersect(a, b); +vec4 layer1 = render(body, vec3(0.32, 0.56, 0.53), fwidth(body) * 2.0); + +float handle = sdLine(p, vec2(0.0, 0.05), vec2(0.0, -0.42), 0.01); +float arc = sdCircle(translate(p, vec2(-0.04, -0.42)), 0.04); +float arcInner = sdCircle(translate(p, vec2(-0.04, -0.42)), 0.03); +handle = opUnion(handle, opSubtract(arcInner, arc)); +vec4 layer0 = render(handle, vec3(0.4, 0.3, 0.28), STROKE_WIDTH); + +vec3 col = bgCol; +col = mix(col, layer0.rgb, layer0.a); +col = mix(col, layer1.rgb, layer1.a); +``` + +### Variant 3: Hexagonal Grid Tiling + +```glsl +vec4 hexagon(vec2 p) { + vec2 q = vec2(p.x * 2.0 * 0.5773503, p.y + p.x * 0.5773503); + vec2 pi = floor(q); + vec2 pf = fract(q); + float v = mod(pi.x + pi.y, 3.0); + float ca = step(1.0, v); + float cb = step(2.0, v); + vec2 ma = step(pf.xy, pf.yx); + float e = dot(ma, 1.0 - pf.yx + ca*(pf.x+pf.y-1.0) + cb*(pf.yx-2.0*pf.xy)); + p = vec2(q.x + floor(0.5 + p.y / 1.5), 4.0 * p.y / 3.0) * 0.5 + 0.5; + float f = length((fract(p) - 0.5) * vec2(1.0, 0.85)); + return vec4(pi + ca - cb * ma, e, f); +} + +#define HEX_SCALE 8.0 +vec4 h = hexagon(HEX_SCALE * p + 0.5 * iTime); +vec3 col = 0.15 + 0.15 * hash1(h.xy + 1.2); +col *= smoothstep(0.10, 0.11, h.z); +col *= smoothstep(0.10, 0.11, h.w); +``` + +### Variant 4: Organic Shapes (Polar SDF) + +```glsl +// Heart SDF +p.y -= 0.25; +float a = atan(p.x, p.y) / 3.141593; +float r = length(p); +float h = abs(a); +float d = (13.0*h - 22.0*h*h + 10.0*h*h*h) / (6.0 - 5.0*h); + +// Pulse animation +float tt = mod(iTime, 1.5) / 1.5; +float ss = pow(tt, 0.2) * 0.5 + 0.5; +ss = 1.0 + ss * 0.5 * sin(tt * 6.2831 * 3.0) * exp(-tt * 4.0); +vec3 col = mix(bgCol, heartCol, smoothstep(-0.01, 0.01, d - r)); +``` + +### Variant 5: Bezier Curve SDF + +```glsl +vec3 solveCubic(float a, float b, float c) { + float p = b - a*a/3.0, p3 = p*p*p; + float q = a*(2.0*a*a - 9.0*b)/27.0 + c; + float d = q*q + 4.0*p3/27.0; + float offset = -a/3.0; + if (d >= 0.0) { + float z = sqrt(d); + vec2 x = (vec2(z,-z) - q) / 2.0; + vec2 uv = sign(x) * pow(abs(x), vec2(1.0/3.0)); + return vec3(offset + uv.x + uv.y); + } + float v = acos(-sqrt(-27.0/p3)*q/2.0) / 3.0; + float m = cos(v), n = sin(v) * 1.732050808; + return vec3(m+m, -n-m, n-m) * sqrt(-p/3.0) + offset; +} + +float sdBezier(vec2 A, vec2 B, vec2 C, vec2 p) { + B = mix(B + vec2(1e-4), B, step(1e-6, abs(B*2.0-A-C))); + vec2 a = B-A, b = A-B*2.0+C, c = a*2.0, d = A-p; + vec3 k = vec3(3.*dot(a,b), 2.*dot(a,a)+dot(d,b), dot(d,a)) / dot(b,b); + vec3 t = clamp(solveCubic(k.x, k.y, k.z), 0.0, 1.0); + vec2 pos = A+(c+b*t.x)*t.x; float dis = length(pos-p); + pos = A+(c+b*t.y)*t.y; dis = min(dis, length(pos-p)); + pos = A+(c+b*t.z)*t.z; dis = min(dis, length(pos-p)); + return dis * signBezier(A, B, C, p); +} +``` + +## Extended 2D SDF Library + +```glsl +// === Extended 2D SDF Library === + +// Rounded Box with independent corner radii (vec4 r = top-right, bottom-right, top-left, bottom-left) +float sdRoundedBox(vec2 p, vec2 b, vec4 r) { + r.xy = (p.x > 0.0) ? r.xy : r.zw; + r.x = (p.y > 0.0) ? r.x : r.y; + vec2 q = abs(p) - b + r.x; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x; +} + +// Oriented Box (from point a to point b with thickness th) +float sdOrientedBox(vec2 p, vec2 a, vec2 b, float th) { + float l = length(b - a); + vec2 d = (b - a) / l; + vec2 q = (p - (a + b) * 0.5); + q = mat2(d.x, -d.y, d.y, d.x) * q; + q = abs(q) - vec2(l, th) * 0.5; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0); +} + +// Arc (sc = vec2(sin,cos) of aperture angle, ra = radius, rb = thickness) +float sdArc(vec2 p, vec2 sc, float ra, float rb) { + p.x = abs(p.x); + return ((sc.y * p.x > sc.x * p.y) ? length(p - sc * ra) : abs(length(p) - ra)) - rb; +} + +// Pie / Sector (c = vec2(sin,cos) of aperture angle) +float sdPie(vec2 p, vec2 c, float r) { + p.x = abs(p.x); + float l = length(p) - r; + float m = length(p - c * clamp(dot(p, c), 0.0, r)); + return max(l, m * sign(c.y * p.x - c.x * p.y)); +} + +// Ring (n = vec2(sin,cos) of aperture, r = radius, th = thickness) +float sdRing(vec2 p, vec2 n, float r, float th) { + p.x = abs(p.x); + float d = length(p); + // If within aperture angle + if (n.y * p.x > n.x * p.y) { + return abs(d - r) - th; + } + // Cap endpoints + return min(length(p - n * r), length(p + n * r)) - th; +} + +// Moon shape +float sdMoon(vec2 p, float d, float ra, float rb) { + p.y = abs(p.y); + float a = (ra * ra - rb * rb + d * d) / (2.0 * d); + float b2 = ra * ra - a * a; + if (d * (p.x * rb * rb - p.y * a * rb * rb - a * b2) > 0.0) + return length(p - vec2(a, sqrt(max(b2, 0.0)))); + return max(length(p) - ra, -(length(p - vec2(d, 0.0)) - rb)); +} + +// Heart (approximate) +float sdHeart(vec2 p) { + p.x = abs(p.x); + if (p.y + p.x > 1.0) + return sqrt(dot(p - vec2(0.25, 0.75), p - vec2(0.25, 0.75))) - sqrt(2.0) / 4.0; + return sqrt(min(dot(p - vec2(0.0, 1.0), p - vec2(0.0, 1.0)), + dot(p - 0.5 * max(p.x + p.y, 0.0), p - 0.5 * max(p.x + p.y, 0.0)))) * + sign(p.x - p.y); +} + +// Vesica (lens shape) +float sdVesica(vec2 p, float w, float h) { + p = abs(p); + float b = sqrt(h * h + w * w * 0.25) / w; + return ((p.y - h) * b * w > p.x * b * h) + ? length(p - vec2(0.0, h)) + : length(p - vec2(-w * 0.5, 0.0)) - b; +} + +// Egg shape +float sdEgg(vec2 p, float he, float ra, float rb) { + p.x = abs(p.x); + float r = (p.y < 0.0) ? ra : rb; + return length(vec2(p.x, p.y - clamp(p.y, -he, he))) - r; +} + +// Equilateral Triangle +float sdEquilateralTriangle(vec2 p, float r) { + const float k = sqrt(3.0); + p.x = abs(p.x) - r; + p.y = p.y + r / k; + if (p.x + k * p.y > 0.0) p = vec2(p.x - k * p.y, -k * p.x - p.y) / 2.0; + p.x -= clamp(p.x, -2.0 * r, 0.0); + return -length(p) * sign(p.y); +} + +// Pentagon +float sdPentagon(vec2 p, float r) { + const vec3 k = vec3(0.809016994, 0.587785252, 0.726542528); + p.x = abs(p.x); + p -= 2.0 * min(dot(vec2(-k.x, k.y), p), 0.0) * vec2(-k.x, k.y); + p -= 2.0 * min(dot(vec2(k.x, k.y), p), 0.0) * vec2(k.x, k.y); + p -= vec2(clamp(p.x, -r * k.z, r * k.z), r); + return length(p) * sign(p.y); +} + +// Hexagon +float sdHexagon(vec2 p, float r) { + const vec3 k = vec3(-0.866025404, 0.5, 0.577350269); + p = abs(p); + p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy; + p -= vec2(clamp(p.x, -k.z * r, k.z * r), r); + return length(p) * sign(p.y); +} + +// Octagon +float sdOctagon(vec2 p, float r) { + const vec3 k = vec3(-0.9238795325, 0.3826834323, 0.4142135623); + p = abs(p); + p -= 2.0 * min(dot(vec2(k.x, k.y), p), 0.0) * vec2(k.x, k.y); + p -= 2.0 * min(dot(vec2(-k.x, k.y), p), 0.0) * vec2(-k.x, k.y); + p -= vec2(clamp(p.x, -k.z * r, k.z * r), r); + return length(p) * sign(p.y); +} + +// Star (n-pointed, m = inner radius ratio) +float sdStar(vec2 p, float r, int n, float m) { + float an = 3.141593 / float(n); + float en = 3.141593 / m; + vec2 acs = vec2(cos(an), sin(an)); + vec2 ecs = vec2(cos(en), sin(en)); + float bn = mod(atan(p.x, p.y), 2.0 * an) - an; + p = length(p) * vec2(cos(bn), abs(sin(bn))); + p -= r * acs; + p += ecs * clamp(-dot(p, ecs), 0.0, r * acs.y / ecs.y); + return length(p) * sign(p.x); +} + +// Quadratic Bezier curve SDF +float sdBezier(vec2 pos, vec2 A, vec2 B, vec2 C) { + vec2 a = B - A; + vec2 b = A - 2.0 * B + C; + vec2 c = a * 2.0; + vec2 d = A - pos; + float kk = 1.0 / dot(b, b); + float kx = kk * dot(a, b); + float ky = kk * (2.0 * dot(a, a) + dot(d, b)) / 3.0; + float kz = kk * dot(d, a); + float res = 0.0; + float p2 = ky - kx * kx; + float q = kx * (2.0 * kx * kx - 3.0 * ky) + kz; + float h = q * q + 4.0 * p2 * p2 * p2; + if (h >= 0.0) { + h = sqrt(h); + vec2 x = (vec2(h, -h) - q) / 2.0; + vec2 uv2 = sign(x) * pow(abs(x), vec2(1.0 / 3.0)); + float t = clamp(uv2.x + uv2.y - kx, 0.0, 1.0); + res = dot(d + (c + b * t) * t, d + (c + b * t) * t); + } else { + float z = sqrt(-p2); + float v = acos(q / (p2 * z * 2.0)) / 3.0; + float m2 = cos(v); + float n2 = sin(v) * 1.732050808; + vec3 t = clamp(vec3(m2 + m2, -n2 - m2, n2 - m2) * z - kx, 0.0, 1.0); + res = min(dot(d + (c + b * t.x) * t.x, d + (c + b * t.x) * t.x), + dot(d + (c + b * t.y) * t.y, d + (c + b * t.y) * t.y)); + } + return sqrt(res); +} + +// Parabola +float sdParabola(vec2 pos, float k) { + pos.x = abs(pos.x); + float ik = 1.0 / k; + float p2 = ik * (pos.y - 0.5 * ik) / 3.0; + float q = 0.25 * ik * ik * pos.x; + float h = q * q - p2 * p2 * p2; + float r = sqrt(abs(h)); + float x = (h > 0.0) ? + pow(q + r, 1.0 / 3.0) + pow(abs(q - r), 1.0 / 3.0) * sign(p2) : + 2.0 * cos(atan(r, q) / 3.0) * sqrt(p2); + return length(pos - vec2(x, k * x * x)) * sign(pos.x - x); +} + +// Cross shape +float sdCross(vec2 p, vec2 b, float r) { + p = abs(p); p = (p.y > p.x) ? p.yx : p.xy; + vec2 q = p - b; + float k = max(q.y, q.x); + vec2 w = (k > 0.0) ? q : vec2(b.y - p.x, -k); + return sign(k) * length(max(w, 0.0)) + r; +} +``` + +## 2D SDF Modifiers + +```glsl +// === 2D SDF Modifiers === + +// Round any 2D SDF +float opRound2D(float d, float r) { return d - r; } + +// Create annular (ring) version of any 2D SDF +float opAnnular2D(float d, float r) { return abs(d) - r; } + +// Repeat a 2D SDF in a grid +vec2 opRepeat2D(vec2 p, float s) { return mod(p + s * 0.5, s) - s * 0.5; } + +// Mirror across arbitrary 2D direction +vec2 opMirror2D(vec2 p, vec2 dir) { + return p - 2.0 * dir * max(dot(p, dir), 0.0); +} +``` + +## Performance & Composition Tips + +**Performance:** +- In polygon SDFs, compare squared distances first; use a single `sqrt` at the end +- For simple scenes, use fixed `px = 2.0/iResolution.y` instead of `fwidth(d)`; use `fwidth` when coordinate scaling is involved +- For many primitives, spatially partition and skip distant ones early +- Supersampling (2x2/3x3) only for offline rendering; for real-time, single-pixel AA with `smoothstep`/`fwidth` is sufficient +- For 2D soft shadow marching, use adaptive step size `dt += max(1.0, abs(sd))` + +**Composition:** +- **SDF + Noise**: `d += noise(p * 10.0 + iTime) * 0.05` to create organic edges +- **SDF + 2D Lighting**: cone marching for soft shadows, query occlusion via `sceneDist()` +- **SDF + Normal Mapping**: finite differences for normals + Blinn-Phong lighting to simulate bump effects +- **SDF + Domain Repetition**: `fract`/`mod` for infinite repetition, `floor` for cell ID +- **SDF + Animation**: parameters driven by `sin/cos` periodic motion, `exp` decay, `mod` looping + +## Further Reading + +Full step-by-step tutorials, mathematical derivations, and advanced usage in [reference](../reference/sdf-2d.md) diff --git a/skills/shader-dev/techniques/sdf-3d.md b/skills/shader-dev/techniques/sdf-3d.md new file mode 100644 index 0000000..0ea28ad --- /dev/null +++ b/skills/shader-dev/techniques/sdf-3d.md @@ -0,0 +1,589 @@ +# 3D Signed Distance Fields (3D SDF) Skill + +## Use Cases + +- Real-time rendering of 3D geometry in ShaderToy / fragment shaders (no traditional meshes needed) +- Complex scenes composed from basic primitives (sphere, box, cylinder, torus, etc.) +- Smooth organic blending (character modeling, fluid blobs, biological forms) +- Infinitely repeating architectural/pattern structures (corridors, gear arrays, grids) +- Precise boolean operations (drilling holes, cutting, intersection) for sculpting geometry + +## Core Principles + +An SDF returns the **signed distance** from any point in space to the nearest surface: positive = outside, negative = inside, zero = surface. + +**Sphere Tracing**: advance along a ray, stepping by the current SDF value (the safe marching distance) at each step. The SDF guarantees no surface exists within that radius. A hit is registered when the distance falls below epsilon. + +Key math: +- Sphere: `f(p) = |p| - r` +- Box: `f(p) = |max(|p|-b, 0)| + min(max(|p-b|), 0)` +- Union: `min(d1, d2)` / Subtraction: `max(d1, -d2)` +- Smooth union: `min(d1,d2) - h^2/4k`, `h = max(k-|d1-d2|, 0)` +- Normal = SDF gradient: `n = normalize(gradient of f(p))` (finite difference approximation) + +## Rendering Pipeline Overview + +1. **SDF Primitive Library** -- `sdSphere`, `sdBox`, `sdEllipsoid`, `sdTorus`, `sdCapsule`, `sdCylinder` +2. **Boolean Operations** -- `opUnion`/`opSubtraction`/`opIntersection` + smooth variants `smin`/`smax` +3. **Scene Definition** -- `map(p)` returns `vec2(distance, materialID)`, combining all primitives +4. **Ray Marching** -- `raycast(ro, rd)` sphere tracing loop (128 steps, adaptive threshold `SURF_DIST * t`) +5. **Normal Calculation** -- tetrahedral differencing (4 map calls, ZERO macro to prevent inlining) +6. **Soft Shadows** -- quadratic stepping with `k*h/t` to estimate occlusion softness, Hermite smoothing +7. **Ambient Occlusion** -- 5-layer sampling along the normal, comparing SDF values with expected distances +8. **Camera + Rendering** -- look-at matrix, multiple lights (sun + sky + SSS), gamma correction, fog + +## Full Code Template + +Runs directly in ShaderToy. Includes multi-primitive scene, smooth blending, soft shadows, AO, and material system. + +**IMPORTANT:** When using the `vec2(distance, materialID)` material system, `smin` needs to handle `vec2` types. The template includes a `vec2 smin(vec2 a, vec2 b, float k)` overload that ensures the material ID is correctly passed through during smooth blending (taking the material of the closer distance). + +```glsl +// 3D SDF Full Rendering Pipeline Template - Runs in ShaderToy +#define AA 1 // Anti-aliasing (1=off, 2=4xAA, 3=9xAA) +#define MAX_STEPS 128 +#define MAX_DIST 40.0 +#define SURF_DIST 0.0001 +#define SHADOW_STEPS 24 +#define SHADOW_SOFTNESS 8.0 +#define SMOOTH_K 0.3 +#define ZERO (min(iFrame, 0)) + +// === SDF Primitives === +float sdSphere(vec3 p, float r) { return length(p) - r; } +float sdBox(vec3 p, vec3 b) { + vec3 d = abs(p) - b; + return min(max(d.x, max(d.y, d.z)), 0.0) + length(max(d, 0.0)); +} +float sdEllipsoid(vec3 p, vec3 r) { + float k0 = length(p / r); float k1 = length(p / (r * r)); + return k0 * (k0 - 1.0) / k1; +} +float sdTorus(vec3 p, vec2 t) { + return length(vec2(length(p.xz) - t.x, p.y)) - t.y; +} +float sdCapsule(vec3 p, vec3 a, vec3 b, float r) { + vec3 pa = p - a, ba = b - a; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + return length(pa - ba * h) - r; +} +float sdCylinder(vec3 p, vec2 h) { + vec2 d = abs(vec2(length(p.xz), p.y)) - h; + return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)); +} + +// === Extended SDF Primitives === +float sdRoundBox(vec3 p, vec3 b, float r) { + vec3 q = abs(p) - b + r; + return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - r; +} + +float sdBoxFrame(vec3 p, vec3 b, float e) { + p = abs(p) - b; + vec3 q = abs(p + e) - e; + return min(min( + length(max(vec3(p.x, q.y, q.z), 0.0)) + min(max(p.x, max(q.y, q.z)), 0.0), + length(max(vec3(q.x, p.y, q.z), 0.0)) + min(max(q.x, max(p.y, q.z)), 0.0)), + length(max(vec3(q.x, q.y, p.z), 0.0)) + min(max(q.x, max(q.y, p.z)), 0.0)); +} + +float sdCone(vec3 p, vec2 c, float h) { + vec2 q = h * vec2(c.x / c.y, -1.0); + vec2 w = vec2(length(p.xz), p.y); + vec2 a = w - q * clamp(dot(w, q) / dot(q, q), 0.0, 1.0); + vec2 b = w - q * vec2(clamp(w.x / q.x, 0.0, 1.0), 1.0); + float k = sign(q.y); + float d = min(dot(a, a), dot(b, b)); + float s = max(k * (w.x * q.y - w.y * q.x), k * (w.y - q.y)); + return sqrt(d) * sign(s); +} + +float sdCappedCone(vec3 p, float h, float r1, float r2) { + vec2 q = vec2(length(p.xz), p.y); + vec2 k1 = vec2(r2, h); + vec2 k2 = vec2(r2 - r1, 2.0 * h); + vec2 ca = vec2(q.x - min(q.x, (q.y < 0.0) ? r1 : r2), abs(q.y) - h); + vec2 cb = q - k1 + k2 * clamp(dot(k1 - q, k2) / dot(k2, k2), 0.0, 1.0); + float s = (cb.x < 0.0 && ca.y < 0.0) ? -1.0 : 1.0; + return s * sqrt(min(dot(ca, ca), dot(cb, cb))); +} + +float sdRoundCone(vec3 p, float r1, float r2, float h) { + float b = (r1 - r2) / h; + float a = sqrt(1.0 - b * b); + vec2 q = vec2(length(p.xz), p.y); + float k = dot(q, vec2(-b, a)); + if (k < 0.0) return length(q) - r1; + if (k > a * h) return length(q - vec2(0.0, h)) - r2; + return dot(q, vec2(a, b)) - r1; +} + +float sdSolidAngle(vec3 p, vec2 c, float ra) { + vec2 q = vec2(length(p.xz), p.y); + float l = length(q) - ra; + float m = length(q - c * clamp(dot(q, c), 0.0, ra)); + return max(l, m * sign(c.y * q.x - c.x * q.y)); +} + +float sdOctahedron(vec3 p, float s) { + p = abs(p); + float m = p.x + p.y + p.z - s; + vec3 q; + if (3.0 * p.x < m) q = p.xyz; + else if (3.0 * p.y < m) q = p.yzx; + else if (3.0 * p.z < m) q = p.zxy; + else return m * 0.57735027; + float k = clamp(0.5 * (q.z - q.y + s), 0.0, s); + return length(vec3(q.x, q.y - s + k, q.z - k)); +} + +float sdPyramid(vec3 p, float h) { + float m2 = h * h + 0.25; + p.xz = abs(p.xz); + p.xz = (p.z > p.x) ? p.zx : p.xz; + p.xz -= 0.5; + vec3 q = vec3(p.z, h * p.y - 0.5 * p.x, h * p.x + 0.5 * p.y); + float s = max(-q.x, 0.0); + float t = clamp((q.y - 0.5 * p.z) / (m2 + 0.25), 0.0, 1.0); + float a = m2 * (q.x + s) * (q.x + s) + q.y * q.y; + float b = m2 * (q.x + 0.5 * t) * (q.x + 0.5 * t) + (q.y - m2 * t) * (q.y - m2 * t); + float d2 = min(q.y, -q.x * m2 - q.y * 0.5) > 0.0 ? 0.0 : min(a, b); + return sqrt((d2 + q.z * q.z) / m2) * sign(max(q.z, -p.y)); +} + +float sdHexPrism(vec3 p, vec2 h) { + const vec3 k = vec3(-0.8660254, 0.5, 0.57735); + p = abs(p); + p.xy -= 2.0 * min(dot(k.xy, p.xy), 0.0) * k.xy; + vec2 d = vec2(length(p.xy - vec2(clamp(p.x, -k.z * h.x, k.z * h.x), h.x)) * sign(p.y - h.x), p.z - h.y); + return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)); +} + +float sdCutSphere(vec3 p, float r, float h) { + float w = sqrt(r * r - h * h); + vec2 q = vec2(length(p.xz), p.y); + float s = max((h - r) * q.x * q.x + w * w * (h + r - 2.0 * q.y), h * q.x - w * q.y); + return (s < 0.0) ? length(q) - r : (q.x < w) ? h - q.y : length(q - vec2(w, h)); +} + +float sdCappedTorus(vec3 p, vec2 sc, float ra, float rb) { + p.x = abs(p.x); + float k = (sc.y * p.x > sc.x * p.y) ? dot(p.xy, sc) : length(p.xy); + return sqrt(dot(p, p) + ra * ra - 2.0 * ra * k) - rb; +} + +float sdLink(vec3 p, float le, float r1, float r2) { + vec3 q = vec3(p.x, max(abs(p.y) - le, 0.0), p.z); + return length(vec2(length(q.xy) - r1, q.z)) - r2; +} + +float sdPlane(vec3 p, vec3 n, float h) { + return dot(p, n) + h; +} + +float sdRhombus(vec3 p, float la, float lb, float h, float ra) { + p = abs(p); + vec2 b = vec2(la, lb); + float f = clamp((dot(b, b - 2.0 * p.xz)) / dot(b, b), -1.0, 1.0); + vec2 q = vec2(length(p.xz - 0.5 * b * vec2(1.0 - f, 1.0 + f)) * sign(p.x * b.y + p.z * b.x - b.x * b.y) - ra, p.y - h); + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)); +} + +// Unsigned distance (exact) +float udTriangle(vec3 p, vec3 a, vec3 b, vec3 c) { + vec3 ba = b - a; vec3 pa = p - a; + vec3 cb = c - b; vec3 pb = p - b; + vec3 ac = a - c; vec3 pc = p - c; + vec3 nor = cross(ba, ac); + return sqrt( + (sign(dot(cross(ba, nor), pa)) + + sign(dot(cross(cb, nor), pb)) + + sign(dot(cross(ac, nor), pc)) < 2.0) + ? min(min( + dot(ba * clamp(dot(ba, pa) / dot(ba, ba), 0.0, 1.0) - pa, + ba * clamp(dot(ba, pa) / dot(ba, ba), 0.0, 1.0) - pa), + dot(cb * clamp(dot(cb, pb) / dot(cb, cb), 0.0, 1.0) - pb, + cb * clamp(dot(cb, pb) / dot(cb, cb), 0.0, 1.0) - pb)), + dot(ac * clamp(dot(ac, pc) / dot(ac, ac), 0.0, 1.0) - pc, + ac * clamp(dot(ac, pc) / dot(ac, ac), 0.0, 1.0) - pc)) + : dot(nor, pa) * dot(nor, pa) / dot(nor, nor)); +} + +// === Boolean Operations === +vec2 opU(vec2 d1, vec2 d2) { return (d1.x < d2.x) ? d1 : d2; } +float smin(float a, float b, float k) { + float h = max(k - abs(a - b), 0.0); + return min(a, b) - h * h * 0.25 / k; +} +vec2 smin(vec2 a, vec2 b, float k) { + // vec2 smin: x=distance (smooth blend), y=materialID (take material of closer distance) + float h = max(k - abs(a.x - b.x), 0.0); + float d = min(a.x, b.x) - h * h * 0.25 / k; + float m = (a.x < b.x) ? a.y : b.y; + return vec2(d, m); +} +float smax(float a, float b, float k) { + float h = max(k - abs(a - b), 0.0); + return max(a, b) + h * h * 0.25 / k; +} + +// === Deformation Operators === + +// Round: soften edges of any SDF +// Usage: sdRound(sdBox(p, vec3(1.0)), 0.1) +float opRound(float d, float r) { return d - r; } + +// Onion: hollow out any SDF into a shell +// Usage: opOnion(sdSphere(p, 1.0), 0.1) — sphere shell of thickness 0.1 +float opOnion(float d, float t) { return abs(d) - t; } + +// Elongate: stretch a shape along axes +// Usage: elongate a sphere into a capsule-like shape +float opElongate(in vec3 p, in vec3 h, in vec3 center, in vec3 size) { + // Generic elongation: subtract h from abs(p), clamp to 0 + vec3 q = abs(p) - h; + // Then evaluate original SDF with max(q, 0.0) + // Return: sdOriginal(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) + return sdBox(max(q, 0.0), size) + min(max(q.x, max(q.y, q.z)), 0.0); // example with box +} + +// Twist: rotate around Y axis based on height +vec3 opTwist(vec3 p, float k) { + float c = cos(k * p.y); + float s = sin(k * p.y); + mat2 m = mat2(c, -s, s, c); + return vec3(m * p.xz, p.y); +} + +// Cheap Bend: bend along X axis based on X position +vec3 opCheapBend(vec3 p, float k) { + float c = cos(k * p.x); + float s = sin(k * p.x); + mat2 m = mat2(c, -s, s, c); + vec2 q = m * p.xy; + return vec3(q, p.z); +} + +// Displacement: add procedural detail to surface +float opDisplace(float d, vec3 p) { + float displacement = sin(20.0 * p.x) * sin(20.0 * p.y) * sin(20.0 * p.z); + return d + displacement * 0.02; +} + +// === 2D-to-3D Constructors === + +// Revolution: rotate a 2D SDF around the Y axis to create a 3D solid of revolution +// sdf2d: any 2D SDF function, o: offset from axis +float opRevolution(vec3 p, float sdf2d_result, float o) { + vec2 q = vec2(length(p.xz) - o, p.y); + // Example: revolve a 2D circle to make a torus + // float d2d = length(q) - 0.3; // 2D circle as cross-section + // return d2d; + return sdf2d_result; // pass pre-computed 2D SDF of vec2(length(p.xz)-o, p.y) +} + +// Extrusion: extend a 2D SDF along the Z axis with finite height +float opExtrusion(vec3 p, float d2d, float h) { + vec2 w = vec2(d2d, abs(p.z) - h); + return min(max(w.x, w.y), 0.0) + length(max(w, 0.0)); +} + +// Usage example: extruded 2D star +// float d2d = sdStar2D(p.xy, 0.5, 5, 2.0); // any 2D SDF +// float d3d = opExtrusion(p, d2d, 0.2); // extrude 0.2 units + +// === Symmetry Operators === + +// Mirror across X axis (most common — bilateral symmetry) +// Place this at the beginning of map() to model only one half +vec3 opSymX(vec3 p) { p.x = abs(p.x); return p; } + +// Mirror across X and Z (four-fold symmetry) +vec3 opSymXZ(vec3 p) { p.xz = abs(p.xz); return p; } + +// Mirror across arbitrary direction +vec3 opMirror(vec3 p, vec3 dir) { + return p - 2.0 * dir * max(dot(p, dir), 0.0); +} + +// === Scene === +vec2 map(vec3 pos) { + vec2 res = vec2(pos.y, 0.0); + // Animated blob cluster + float dBlob = 2.0; + for (int i = 0; i < 8; i++) { + float fi = float(i); + float t = iTime * (fract(fi * 412.531 + 0.513) - 0.5) * 2.0; + vec3 offset = sin(t + fi * vec3(52.5126, 64.627, 632.25)) * vec3(2.0, 2.0, 0.8); + float radius = mix(0.3, 0.6, fract(fi * 412.531 + 0.5124)); + dBlob = smin(dBlob, sdSphere(pos + offset, radius), SMOOTH_K); + } + res = opU(res, vec2(dBlob, 1.0)); + float dBox = sdBox(pos - vec3(3.0, 0.4, 0.0), vec3(0.3, 0.4, 0.3)); + res = opU(res, vec2(dBox, 2.0)); + float dTorus = sdTorus((pos - vec3(-3.0, 0.5, 0.0)).xzy, vec2(0.4, 0.1)); + res = opU(res, vec2(dTorus, 3.0)); + // CSG subtraction: sphere minus box + float dCSG = sdSphere(pos - vec3(0.0, 0.5, 3.0), 0.5); + dCSG = max(dCSG, -sdBox(pos - vec3(0.0, 0.5, 3.0), vec3(0.3))); + res = opU(res, vec2(dCSG, 4.0)); + return res; +} + +// === Normals === +vec3 calcNormal(vec3 pos) { + vec3 n = vec3(0.0); + for (int i = ZERO; i < 4; i++) { + vec3 e = 0.5773 * (2.0 * vec3((((i+3)>>1)&1), ((i>>1)&1), (i&1)) - 1.0); + n += e * map(pos + 0.0005 * e).x; + } + return normalize(n); +} + +// === Shadows === +float calcSoftshadow(vec3 ro, vec3 rd, float mint, float tmax) { + float res = 1.0, t = mint; + for (int i = ZERO; i < SHADOW_STEPS; i++) { + float h = map(ro + rd * t).x; + float s = clamp(SHADOW_SOFTNESS * h / t, 0.0, 1.0); + res = min(res, s); + t += clamp(h, 0.01, 0.2); + if (res < 0.004 || t > tmax) break; + } + res = clamp(res, 0.0, 1.0); + return res * res * (3.0 - 2.0 * res); +} + +// === AO === +float calcAO(vec3 pos, vec3 nor) { + float occ = 0.0, sca = 1.0; + for (int i = ZERO; i < 5; i++) { + float h = 0.01 + 0.12 * float(i) / 4.0; + float d = map(pos + h * nor).x; + occ += (h - d) * sca; + sca *= 0.95; + if (occ > 0.35) break; + } + return clamp(1.0 - 3.0 * occ, 0.0, 1.0) * (0.5 + 0.5 * nor.y); +} + +// === Ray Marching === +vec2 raycast(vec3 ro, vec3 rd) { + vec2 res = vec2(-1.0); + float t = 0.01; + for (int i = 0; i < MAX_STEPS && t < MAX_DIST; i++) { + vec2 h = map(ro + rd * t); + if (abs(h.x) < SURF_DIST * t) { res = vec2(t, h.y); break; } + t += h.x; + } + return res; +} + +// === Camera === +mat3 setCamera(vec3 ro, vec3 ta, float cr) { + vec3 cw = normalize(ta - ro); + vec3 cp = vec3(sin(cr), cos(cr), 0.0); + vec3 cu = normalize(cross(cw, cp)); + vec3 cv = cross(cu, cw); + return mat3(cu, cv, cw); +} + +// === Rendering === +vec3 render(vec3 ro, vec3 rd) { + vec3 col = vec3(0.7, 0.7, 0.9) - max(rd.y, 0.0) * 0.3; + vec2 res = raycast(ro, rd); + float t = res.x, m = res.y; + if (m > -0.5) { + vec3 pos = ro + t * rd; + vec3 nor = (m < 0.5) ? vec3(0.0, 1.0, 0.0) : calcNormal(pos); + vec3 ref = reflect(rd, nor); + vec3 mate = 0.2 + 0.2 * sin(m * 2.0 + vec3(0.0, 1.0, 2.0)); + if (m < 0.5) mate = vec3(0.15); + float occ = calcAO(pos, nor); + vec3 lin = vec3(0.0); + // Key light + { + vec3 lig = normalize(vec3(-0.5, 0.4, -0.6)); + vec3 hal = normalize(lig - rd); + float dif = clamp(dot(nor, lig), 0.0, 1.0); + dif *= calcSoftshadow(pos, lig, 0.02, 2.5); + float spe = pow(clamp(dot(nor, hal), 0.0, 1.0), 16.0); + spe *= dif * (0.04 + 0.96 * pow(clamp(1.0 - dot(hal, lig), 0.0, 1.0), 5.0)); + lin += mate * 2.20 * dif * vec3(1.30, 1.00, 0.70); + lin += 5.00 * spe * vec3(1.30, 1.00, 0.70); + } + // Sky light + { + float dif = sqrt(clamp(0.5 + 0.5 * nor.y, 0.0, 1.0)) * occ; + lin += mate * 0.60 * dif * vec3(0.40, 0.60, 1.15); + } + // Subsurface scattering approximation + { + float dif = pow(clamp(1.0 + dot(nor, rd), 0.0, 1.0), 2.0) * occ; + lin += mate * 0.25 * dif; + } + col = lin; + col = mix(col, vec3(0.7, 0.7, 0.9), 1.0 - exp(-0.0001 * t * t * t)); + } + return clamp(col, 0.0, 1.0); +} + +// === Main Function === +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 mo = iMouse.xy / iResolution.xy; + float time = 32.0 + iTime * 1.5; + vec3 ta = vec3(0.0, 0.0, 0.0); + vec3 ro = ta + vec3(4.5 * cos(0.1 * time + 7.0 * mo.x), 2.2, + 4.5 * sin(0.1 * time + 7.0 * mo.x)); + mat3 ca = setCamera(ro, ta, 0.0); + vec3 tot = vec3(0.0); +#if AA > 1 + for (int m = ZERO; m < AA; m++) + for (int n = ZERO; n < AA; n++) { + vec2 o = vec2(float(m), float(n)) / float(AA) - 0.5; + vec2 p = (2.0 * (fragCoord + o) - iResolution.xy) / iResolution.y; +#else + vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; +#endif + vec3 rd = ca * normalize(vec3(p, 2.5)); + vec3 col = render(ro, rd); + col = pow(col, vec3(0.4545)); + tot += col; +#if AA > 1 + } + tot /= float(AA * AA); +#endif + fragColor = vec4(tot, 1.0); +} +``` + +## Common Variants + +### Variant 1: Dynamic Organic Body (Smooth Blob Animation) + +```glsl +vec2 map(vec3 p) { + float d = 2.0; + for (int i = 0; i < 16; i++) { + float fi = float(i); + float t = iTime * (fract(fi * 412.531 + 0.513) - 0.5) * 2.0; + d = smin(sdSphere(p + sin(t + fi * vec3(52.5126, 64.627, 632.25)) * vec3(2.0, 2.0, 0.8), + mix(0.5, 1.0, fract(fi * 412.531 + 0.5124))), d, 0.4); + } + return vec2(d, 1.0); +} +``` + +### Variant 2: Infinite Repeating Corridor (Domain Repetition) + +```glsl +float repeat(float v, float c) { return mod(v, c) - c * 0.5; } + +float amod(inout vec2 p, float count) { + float an = 6.283185 / count; + float a = atan(p.y, p.x) + an * 0.5; + float c = floor(a / an); + a = mod(a, an) - an * 0.5; + p = vec2(cos(a), sin(a)) * length(p); + return c; +} + +vec2 map(vec3 p) { + p.z = repeat(p.z, 4.0); + p.x += 2.0 * sin(p.z * 0.1); + float d = -sdBox(p, vec3(2.0, 2.0, 20.0)); + d = max(d, -sdBox(p, vec3(1.8, 1.8, 1.9))); + d = min(d, sdCylinder(p - vec3(1.5, -2.0, 0.0), vec2(0.1, 2.0))); + return vec2(d, 1.0); +} +``` + +### Variant 3: Character/Creature Modeling + +```glsl +vec2 sdStick(vec3 p, vec3 a, vec3 b, float r1, float r2) { + vec3 pa = p - a, ba = b - a; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + return vec2(length(pa - ba * h) - mix(r1, r2, h * h * (3.0 - 2.0 * h)), h); +} + +vec2 map(vec3 pos) { + float d = sdEllipsoid(pos, vec3(0.25, 0.3, 0.25)); // body + d = smin(d, sdEllipsoid(pos - vec3(0.0, 0.35, 0.02), + vec3(0.12, 0.15, 0.13)), 0.1); // head + vec2 arm = sdStick(abs(pos.x) > 0.0 ? vec3(abs(pos.x), pos.yz) : pos, + vec3(0.18, 0.2, -0.05), vec3(0.35, -0.1, -0.15), 0.03, 0.05); + d = smin(d, arm.x, 0.04); // arms + d = smax(d, -sdEllipsoid(pos - vec3(0.0, 0.3, 0.15), + vec3(0.08, 0.03, 0.1)), 0.03); // mouth carving + return vec2(d, 1.0); +} +``` + +### Variant 4: Symmetry Optimization + +```glsl +vec2 rot45(vec2 v) { return vec2(v.x - v.y, v.y + v.x) * 0.707107; } + +vec2 map(vec3 p) { + float d = sdSphere(p, 0.12); + // Octahedral symmetry: 18-gear evaluations reduced to 4 + vec3 qx = vec3(rot45(p.zy), p.x); + if (abs(qx.x) > abs(qx.y)) qx = qx.zxy; + vec3 qy = vec3(rot45(p.xz), p.y); + if (abs(qy.x) > abs(qy.y)) qy = qy.zxy; + vec3 qz = vec3(rot45(p.yx), p.z); + if (abs(qz.x) > abs(qz.y)) qz = qz.zxy; + vec3 qa = abs(p); + qa = (qa.x > qa.y && qa.x > qa.z) ? p.zxy : (qa.z > qa.y) ? p.yzx : p.xyz; + d = min(d, min(min(gear(qa, 0.0), gear(qx, 1.0)), min(gear(qy, 1.0), gear(qz, 1.0)))); + return vec2(d, 1.0); +} +``` + +### Variant 5: PBR Material Rendering + +```glsl +float D_GGX(float NoH, float roughness) { + float a = roughness * roughness; float a2 = a * a; + float d = NoH * NoH * (a2 - 1.0) + 1.0; + return a2 / (3.14159 * d * d); +} +vec3 F_Schlick(float VoH, vec3 f0) { + return f0 + (1.0 - f0) * pow(1.0 - VoH, 5.0); +} +vec3 pbrLighting(vec3 pos, vec3 nor, vec3 rd, vec3 albedo, float roughness, float metallic) { + vec3 lig = normalize(vec3(-0.5, 0.4, -0.6)); + vec3 hal = normalize(lig - rd); + vec3 f0 = mix(vec3(0.04), albedo, metallic); + float NoL = max(dot(nor, lig), 0.0); + float NoH = max(dot(nor, hal), 0.0); + float VoH = max(dot(-rd, hal), 0.0); + vec3 spec = D_GGX(NoH, roughness) * F_Schlick(VoH, f0) * 0.25; + vec3 diff = albedo * (1.0 - metallic) / 3.14159; + float shadow = calcSoftshadow(pos, lig, 0.02, 2.5); + return (diff + spec) * NoL * shadow * vec3(1.3, 1.0, 0.7) * 3.0; +} +``` + +## Performance & Composition + +### Performance Optimization Tips + +- **Bounding volume acceleration**: test ray against AABB first to narrow `tmin/tmax`, avoiding wasted steps in empty regions +- **Sub-scene bounding**: in `map()`, use a cheap `sdBox` to check proximity before computing the precise SDF +- **Adaptive step size**: `abs(h.x) < SURF_DIST * t` -- looser tolerance at distance, stricter up close +- **Prevent compiler inlining**: `#define ZERO (min(iFrame, 0))` + loop prevents `calcNormal` from inlining map 4 times +- **Exploit symmetry**: fold into the fundamental domain, reducing 18 evaluations to 4 + +### Common Composition Techniques + +- **Noise displacement**: `d += 0.05 * sin(p.x*10.)*sin(p.y*10.)*sin(p.z*10.)` adds organic detail; breaks the Lipschitz condition, so step size should be multiplied by 0.5~0.7 +- **Bump mapping**: perturb only during normal calculation, leaving ray marching unaffected for better performance +- **Domain transforms**: warp coordinates before entering map (bending, polar coordinate transforms, etc.) +- **Procedural animation**: bone angles driven by time to position primitives, `smin` ensures smooth joints +- **Motion blur**: multi-frame temporal sampling averaged + +## Further Reading + +Full step-by-step tutorials, mathematical derivations, and advanced usage in [reference](../reference/sdf-3d.md) diff --git a/skills/shader-dev/techniques/sdf-tricks.md b/skills/shader-dev/techniques/sdf-tricks.md new file mode 100644 index 0000000..d32b9fc --- /dev/null +++ b/skills/shader-dev/techniques/sdf-tricks.md @@ -0,0 +1,100 @@ +# SDF Advanced Tricks & Optimization + +## Use Cases +- Optimizing complex SDF scenes for real-time performance +- Adding fine detail to SDF surfaces without increasing geometric complexity +- Creating special effects with SDF manipulation (hollowing, layered edges, interior structures) +- Debugging and visualizing SDF fields + +## Core Techniques + +### Hollowing (Shell Creation) +Convert any solid SDF into a thin shell: +```glsl +float hollowed = abs(sdf) - thickness; +// Example: hollow sphere with 0.02 wall thickness +float d = abs(sdSphere(p, 1.0)) - 0.02; +``` + +### Layered Edges (Concentric Contour Lines) +Create equidistant contour rings from any SDF: +```glsl +float spacing = 0.2; +float thickness = 0.02; +float layered = abs(mod(d + spacing * 0.5, spacing) - spacing * 0.5) - thickness; +``` +Useful for: topographic map effects, neon outlines, energy shields, wireframe-like rendering. + +### FBM Detail on SDF (Distance-Based LOD) +Add procedural noise detail only where it's visible — near the camera: +```glsl +float map(vec3 p) { + float d = sdBasicShape(p); + // Only add expensive FBM detail when close to surface + if (d < 1.0) { + d += 0.02 * fbm(p * 8.0) * smoothstep(1.0, 0.0, d); + } + return d; +} +``` +**Critical**: The `smoothstep` fade prevents the FBM from disrupting the SDF's Lipschitz continuity far from the surface, which would cause ray marching to overshoot. + +### SDF Bounding Volumes (Performance Optimization) +Skip expensive SDF evaluation when the point is far from the object: +```glsl +float map(vec3 p) { + // Cheap bounding sphere test first + float bound = sdSphere(p - objectCenter, boundingRadius); + if (bound > 0.1) return bound; // far away — return bounding distance + // Expensive detailed SDF only when close + return complexSDF(p); +} +``` +For scenes with multiple distant objects, this can provide 5-10x speedup. + +### Binary Search Refinement +After ray marching finds an approximate hit, refine with binary search for sub-pixel precision: +```glsl +// After ray march loop finds t where map(ro+rd*t) < epsilon: +for (int i = 0; i < 6; i++) { + float mid = map(ro + rd * t); + t += mid * 0.5; // or use proper bisection: + // float dt = step * 0.5^i; + // t += (map(ro+rd*t) > 0.0) ? dt : -dt; +} +``` +Especially useful for: sharp edge rendering, precise shadow termination, accurate reflection points. + +### XOR Boolean Operation +Create interesting geometric patterns by combining SDFs with XOR: +```glsl +float opXor(float d1, float d2) { + return max(min(d1, d2), -max(d1, d2)); +} +// Creates a "difference of unions" — geometry exists where exactly one shape is present +``` + +### Interior SDF Structures +Use the sign of the SDF to create interior geometry: +```glsl +float interiorPattern(vec3 p) { + float outer = sdSphere(p, 1.0); + float inner = sdBox(fract(p * 4.0) - 0.5, vec3(0.1)); // repeating inner pattern + return (outer < 0.0) ? max(outer, inner) : outer; // inner visible only inside +} +``` + +## SDF Debugging Visualization + +```glsl +// Visualize SDF distance as color bands +vec3 debugSDF(float d) { + vec3 col = (d > 0.0) ? vec3(0.9, 0.6, 0.3) : vec3(0.4, 0.7, 0.85); // outside/inside + col *= 1.0 - exp(-6.0 * abs(d)); // darken near surface + col *= 0.8 + 0.2 * cos(150.0 * d); // distance bands + col = mix(col, vec3(1.0), 1.0 - smoothstep(0.0, 0.01, abs(d))); // white at surface + return col; +} +``` + +→ For deeper details, see [reference/sdf-tricks.md](../reference/sdf-tricks.md) diff --git a/skills/shader-dev/techniques/shadow-techniques.md b/skills/shader-dev/techniques/shadow-techniques.md new file mode 100644 index 0000000..839d7de --- /dev/null +++ b/skills/shader-dev/techniques/shadow-techniques.md @@ -0,0 +1,776 @@ +# SDF Soft Shadow Techniques + +## Core Principles + +March from the surface point toward the light source, using the **ratio of nearest distance to marching distance** to estimate penumbra width. + +### Key Formulas + +Classic formula: `shadow = min(shadow, k * h / t)` +- `h` = SDF value at current position, `t` = distance traveled, `k` = penumbra hardness + +Improved formula (geometric triangulation) — eliminates sharp edge banding artifacts: +``` +y = h² / (2 * ph) // ph = SDF value from previous step +d = sqrt(h² - y²) // true closest distance perpendicular to the ray +shadow = min(shadow, d / (w * max(0, t - y))) +``` + +Negative extension — allows `res` to drop to -1, remapped with a C1 continuous function to eliminate hard creases: +``` +res = max(res, -1.0) +shadow = 0.25 * (1 + res)² * (2 - res) +``` +This is equivalent to `smoothstep` over [-1, 1] instead of [0, 1]. The step size is clamped with `clamp(h, 0.005, 0.50)` to ensure the ray penetrates slightly into geometry, capturing both outer and inner penumbra. This produces results close to ground truth for varying light sizes. + +## Implementation Steps + +### Step 1: Scene SDF + +```glsl +float sdSphere(vec3 p, float r) { return length(p) - r; } +float sdPlane(vec3 p) { return p.y; } +float sdRoundBox(vec3 p, vec3 b, float r) { + vec3 q = abs(p) - b; + return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - r; +} + +float map(vec3 p) { + float d = sdPlane(p); + d = min(d, sdSphere(p - vec3(0.0, 0.5, 0.0), 0.5)); + d = min(d, sdRoundBox(p - vec3(-1.2, 0.3, 0.5), vec3(0.3), 0.05)); + return d; +} +``` + +### Step 2: Classic Soft Shadow + +```glsl +// Classic SDF soft shadow +float calcSoftShadow(vec3 ro, vec3 rd, float mint, float tmax) { + float res = 1.0; + float t = mint; + for (int i = 0; i < MAX_SHADOW_STEPS; i++) { + float h = map(ro + rd * t); + float s = clamp(SHADOW_K * h / t, 0.0, 1.0); + res = min(res, s); + t += clamp(h, MIN_STEP, MAX_STEP); + if (res < 0.004 || t > tmax) break; + } + res = clamp(res, 0.0, 1.0); + return res * res * (3.0 - 2.0 * res); // smoothstep smoothing +} +``` + +### Step 3: Improved Soft Shadow (Geometric Triangulation) + +```glsl +// Improved version - geometric triangulation using adjacent SDF values +float calcSoftShadowImproved(vec3 ro, vec3 rd, float mint, float tmax, float w) { + float res = 1.0; + float t = mint; + float ph = 1e10; + for (int i = 0; i < MAX_SHADOW_STEPS; i++) { + float h = map(ro + rd * t); + float y = h * h / (2.0 * ph); + float d = sqrt(h * h - y * y); + res = min(res, d / (w * max(0.0, t - y))); + ph = h; + t += h; + if (res < 0.0001 || t > tmax) break; + } + res = clamp(res, 0.0, 1.0); + return res * res * (3.0 - 2.0 * res); +} +``` + +### Step 4: Negative Extension (Smoothest Penumbra) + +```glsl +// Negative extension - allows res to go negative for C1 continuous penumbra +float calcSoftShadowSmooth(vec3 ro, vec3 rd, float mint, float tmax, float w) { + float res = 1.0; + float t = mint; + for (int i = 0; i < MAX_SHADOW_STEPS; i++) { + float h = map(ro + rd * t); + res = min(res, h / (w * t)); + t += clamp(h, MIN_STEP, MAX_STEP); + if (res < -1.0 || t > tmax) break; + } + res = max(res, -1.0); + return 0.25 * (1.0 + res) * (1.0 + res) * (2.0 - res); +} +``` + +### Step 5: Bounding Volume Optimization + +```glsl +// plane clipping -- clip the ray to the scene's upper bound +float tp = (SCENE_Y_MAX - ro.y) / rd.y; +if (tp > 0.0) tmax = min(tmax, tp); + +// AABB bounding box clipping +vec2 iBox(vec3 ro, vec3 rd, vec3 rad) { + vec3 m = 1.0 / rd; + vec3 n = m * ro; + vec3 k = abs(m) * rad; + vec3 t1 = -n - k; + vec3 t2 = -n + k; + float tN = max(max(t1.x, t1.y), t1.z); + float tF = min(min(t2.x, t2.y), t2.z); + if (tN > tF || tF < 0.0) return vec2(-1.0); + return vec2(tN, tF); +} + +// usage: return 1.0 immediately if the ray misses the bounding box entirely +vec2 dis = iBox(ro, rd, BOUND_SIZE); +if (dis.y < 0.0) return 1.0; +tmin = max(tmin, dis.x); +tmax = min(tmax, dis.y); +``` + +### Step 6: Shadow Color Rendering + +```glsl +// Classic colored shadow +vec3 shadowColor = vec3(sha, sha * sha * 0.5 + 0.5 * sha, sha * sha); + +// per-channel power (penumbra region shifts warm) +vec3 shadowColor = pow(vec3(sha), vec3(1.0, 1.2, 1.5)); +``` + +### Step 7: Integration with Lighting Model + +```glsl +vec3 sunDir = normalize(vec3(-0.5, 0.4, -0.6)); +vec3 hal = normalize(sunDir - rd); + +float dif = clamp(dot(nor, sunDir), 0.0, 1.0); +if (dif > 0.0001) + dif *= calcSoftShadow(pos + nor * 0.01, sunDir, 0.02, 8.0); + +float spe = pow(clamp(dot(nor, hal), 0.0, 1.0), 16.0); +spe *= dif; + +vec3 col = vec3(0.0); +col += albedo * 2.0 * dif * vec3(1.0, 0.9, 0.8); +col += 5.0 * spe * vec3(1.0, 0.9, 0.8); +col += albedo * 0.5 * clamp(0.5 + 0.5 * nor.y, 0.0, 1.0) * vec3(0.4, 0.6, 1.0); +``` + +## Complete Code Template + +Runs directly in ShaderToy, with A/B comparison of three soft shadow techniques. + +```glsl +#define ZERO (min(iFrame, 0)) + +// ---- Adjustable Parameters ---- +#define MAX_MARCH_STEPS 128 +#define MAX_SHADOW_STEPS 64 // 16~128 +#define SHADOW_K 8.0 // 4~64, higher = harder +#define SHADOW_MINT 0.02 // 0.01~0.05 +#define SHADOW_TMAX 8.0 +#define SHADOW_MIN_STEP 0.01 +#define SHADOW_MAX_STEP 0.20 +#define SHADOW_W 0.10 // improved version penumbra width + +// 0=classic, 1=improved(Aaltonen), 2=negative extension +#define SHADOW_TECHNIQUE 0 + +// ---- SDF Primitives ---- +float sdSphere(vec3 p, float r) { return length(p) - r; } +float sdPlane(vec3 p) { return p.y; } +float sdRoundBox(vec3 p, vec3 b, float r) { + vec3 q = abs(p) - b; + return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - r; +} +float sdTorus(vec3 p, vec2 t) { + vec2 q = vec2(length(p.xz) - t.x, p.y); + return length(q) - t.y; +} + +// ---- Scene SDF ---- +float map(vec3 p) { + float d = sdPlane(p); + d = min(d, sdSphere(p - vec3(0.0, 0.5, 0.0), 0.5)); + d = min(d, sdRoundBox(p - vec3(-1.2, 0.30, 0.5), vec3(0.25), 0.05)); + d = min(d, sdTorus(p - vec3(1.2, 0.25, -0.3), vec2(0.40, 0.08))); + return d; +} + +// ---- Normal ---- +vec3 calcNormal(vec3 p) { + vec2 e = vec2(0.0005, 0.0); + return normalize(vec3( + map(p + e.xyy) - map(p - e.xyy), + map(p + e.yxy) - map(p - e.yxy), + map(p + e.yyx) - map(p - e.yyx))); +} + +// ---- Raymarching ---- +float castRay(vec3 ro, vec3 rd) { + float t = 0.0; + for (int i = ZERO; i < MAX_MARCH_STEPS; i++) { + float h = map(ro + rd * t); + if (h < 0.0002) return t; + t += h; + if (t > 20.0) break; + } + return -1.0; +} + +// ---- Bounding Volume Clipping ---- +float clipTmax(vec3 ro, vec3 rd, float tmax, float yMax) { + float tp = (yMax - ro.y) / rd.y; + if (tp > 0.0) tmax = min(tmax, tp); + return tmax; +} + +// ---- Shadow: Classic ---- +float softShadowClassic(vec3 ro, vec3 rd, float mint, float tmax) { + tmax = clipTmax(ro, rd, tmax, 1.5); + float res = 1.0, t = mint; + for (int i = ZERO; i < MAX_SHADOW_STEPS; i++) { + float h = map(ro + rd * t); + float s = clamp(SHADOW_K * h / t, 0.0, 1.0); + res = min(res, s); + t += clamp(h, SHADOW_MIN_STEP, SHADOW_MAX_STEP); + if (res < 0.004 || t > tmax) break; + } + res = clamp(res, 0.0, 1.0); + return res * res * (3.0 - 2.0 * res); +} + +// ---- Shadow: Improved ---- +float softShadowImproved(vec3 ro, vec3 rd, float mint, float tmax, float w) { + tmax = clipTmax(ro, rd, tmax, 1.5); + float res = 1.0, t = mint, ph = 1e10; + for (int i = ZERO; i < MAX_SHADOW_STEPS; i++) { + float h = map(ro + rd * t); + float y = h * h / (2.0 * ph); + float d = sqrt(h * h - y * y); + res = min(res, d / (w * max(0.0, t - y))); + ph = h; + t += h; + if (res < 0.0001 || t > tmax) break; + } + res = clamp(res, 0.0, 1.0); + return res * res * (3.0 - 2.0 * res); +} + +// ---- Shadow: Negative Extension ---- +float softShadowSmooth(vec3 ro, vec3 rd, float mint, float tmax, float w) { + tmax = clipTmax(ro, rd, tmax, 1.5); + float res = 1.0, t = mint; + for (int i = ZERO; i < MAX_SHADOW_STEPS; i++) { + float h = map(ro + rd * t); + res = min(res, h / (w * t)); + t += clamp(h, SHADOW_MIN_STEP, SHADOW_MAX_STEP); + if (res < -1.0 || t > tmax) break; + } + res = max(res, -1.0); + return 0.25 * (1.0 + res) * (1.0 + res) * (2.0 - res); +} + +// ---- Unified Interface ---- +float calcSoftShadow(vec3 ro, vec3 rd, float mint, float tmax) { + #if SHADOW_TECHNIQUE == 0 + return softShadowClassic(ro, rd, mint, tmax); + #elif SHADOW_TECHNIQUE == 1 + return softShadowImproved(ro, rd, mint, tmax, SHADOW_W); + #else + return softShadowSmooth(ro, rd, mint, tmax, SHADOW_W); + #endif +} + +// ---- AO ---- +float calcAO(vec3 p, vec3 n) { + float occ = 0.0, sca = 1.0; + for (int i = ZERO; i < 5; i++) { + float h = 0.01 + 0.12 * float(i) / 4.0; + float d = map(p + h * n); + occ += (h - d) * sca; + sca *= 0.95; + } + return clamp(1.0 - 3.0 * occ, 0.0, 1.0); +} + +// ---- Checkerboard ---- +float checkerboard(vec2 p) { + vec2 q = floor(p); + return mix(0.3, 1.0, mod(q.x + q.y, 2.0)); +} + +// ---- Render ---- +vec3 render(vec3 ro, vec3 rd) { + vec3 col = vec3(0.7, 0.75, 0.85) - 0.3 * rd.y; + float t = castRay(ro, rd); + if (t < 0.0) return col; + + vec3 pos = ro + rd * t; + vec3 nor = calcNormal(pos); + vec3 albedo = vec3(0.18); + if (pos.y < 0.001) + albedo = vec3(0.08 + 0.15 * checkerboard(pos.xz * 2.0)); + + vec3 sunDir = normalize(vec3(-0.5, 0.4, -0.6)); + vec3 hal = normalize(sunDir - rd); + + float dif = clamp(dot(nor, sunDir), 0.0, 1.0); + if (dif > 0.0001) + dif *= calcSoftShadow(pos + nor * 0.001, sunDir, SHADOW_MINT, SHADOW_TMAX); + + float spe = pow(clamp(dot(nor, hal), 0.0, 1.0), 16.0); + spe *= dif; + float fre = pow(clamp(1.0 + dot(nor, rd), 0.0, 1.0), 5.0); + spe *= 0.04 + 0.96 * fre; + + float sky = clamp(0.5 + 0.5 * nor.y, 0.0, 1.0); + float occ = calcAO(pos, nor); + + vec3 lin = vec3(0.0); + lin += 2.5 * dif * vec3(1.30, 1.00, 0.70); + lin += 8.0 * spe * vec3(1.30, 1.00, 0.70); + lin += 0.5 * sky * vec3(0.40, 0.60, 1.00) * occ; + lin += 0.25 * occ * vec3(0.40, 0.50, 0.60); + + col = albedo * lin; + col = pow(col, vec3(0.4545)); + return col; +} + +// ---- Camera ---- +mat3 setCamera(vec3 ro, vec3 ta) { + vec3 cw = normalize(ta - ro); + vec3 cu = normalize(cross(cw, vec3(0.0, 1.0, 0.0))); + vec3 cv = cross(cu, cw); + return mat3(cu, cv, cw); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; + float an = 0.3 * iTime; + vec3 ro = vec3(3.5 * sin(an), 1.8, 3.5 * cos(an)); + vec3 ta = vec3(0.0, 0.3, 0.0); + mat3 ca = setCamera(ro, ta); + vec3 rd = ca * normalize(vec3(p, 1.8)); + vec3 col = render(ro, rd); + fragColor = vec4(col, 1.0); +} +``` + +## Standalone HTML + WebGL2 Template + +When generating standalone HTML files, use the following complete template. Key points: +- Must use `canvas.getContext('webgl2')` +- Shaders use `#version 300 es` +- Entry function is `void main()`, not `void mainImage()` +- Use `gl_FragCoord.xy` to get pixel coordinates (available in WebGL2) + +```html + + + + + Soft Shadows - SDF Raymarching + + + + + + + +``` + +## Common Variants + +### Analytic Sphere Shadow + +```glsl +vec2 sphDistances(vec3 ro, vec3 rd, vec4 sph) { + vec3 oc = ro - sph.xyz; + float b = dot(oc, rd); + float c = dot(oc, oc) - sph.w * sph.w; + float h = b * b - c; + float d = sqrt(max(0.0, sph.w * sph.w - h)) - sph.w; + return vec2(d, -b - sqrt(max(h, 0.0))); +} +float sphSoftShadow(vec3 ro, vec3 rd, vec4 sph, float k) { + vec2 r = sphDistances(ro, rd, sph); + if (r.y > 0.0) + return clamp(k * max(r.x, 0.0) / r.y, 0.0, 1.0); + return 1.0; +} +``` + +### Terrain Heightfield Shadow + +```glsl +float terrainShadow(vec3 ro, vec3 rd, float dis) { + float minStep = clamp(dis * 0.01, 0.5, 50.0); + float res = 1.0, t = 0.01; + for (int i = 0; i < 80; i++) { + vec3 p = ro + t * rd; + float h = p.y - terrainMap(p.xz); + res = min(res, 16.0 * h / t); + t += max(minStep, h); + if (res < 0.001 || p.y > MAX_TERRAIN_HEIGHT) break; + } + return clamp(res, 0.0, 1.0); +} +``` + +### Per-Material Soft/Hard Blending + +```glsl +float hsha = 1.0; // global variable, set per material in map() +float mapWithShadowHardness(vec3 p) { + float d = sdPlane(p); hsha = 1.0; + float dChar = sdCharacter(p); + if (dChar < d) { d = dChar; hsha = 0.0; } + return d; +} +// in shadow loop: res = min(res, mix(1.0, SHADOW_K * h / t, hsha)); +``` + +### Multi-Layer Shadow Compositing + +```glsl +float sha_terrain = terrainShadow(pos, sunDir, 0.02); +float sha_trees = treesShadow(pos, sunDir); +float sha_clouds = cloudShadow(pos, sunDir); +float sha = sha_terrain * sha_trees; +sha *= smoothstep(-0.3, -0.1, sha_clouds); +dif *= sha; +``` + +### Volumetric Light / God Rays + +```glsl +float godRays(vec3 ro, vec3 rd, float tmax, vec3 sunDir) { + float v = 0.0, dt = 0.15; + float t = dt * fract(texelFetch(iChannel0, ivec2(fragCoord) & 255, 0).x); + for (int i = 0; i < 32; i++) { + if (t > tmax) break; + vec3 p = ro + rd * t; + float sha = calcSoftShadow(p, sunDir, 0.02, 8.0); + v += sha * exp(-0.2 * t); + t += dt; + } + v /= 32.0; + return v * v; +} +// col += intensity * godRays(...) * vec3(1.0, 0.75, 0.4); +``` + +## Performance & Composition + +**Performance optimization:** +- Bounding volume clipping (plane/AABB) can reduce 30-70% of wasted iterations +- Step clamping `clamp(h, minStep, maxStep)` prevents stalling / skipping thin objects +- Early exit: `res < 0.004` (classic) or `res < -1.0` (negative extension) +- Simplified `map()` omitting material calculations, returning distance only +- Only compute shadow when `dif > 0.0001`; skip for backlit faces +- Iteration count: simple scenes 16~32, complex FBM 64~128, terrain ~80 +- `#define ZERO (min(iFrame,0))` prevents compiler loop unrolling + +**Composition tips:** +- AO: shadows control direct light, AO controls indirect light, `col = diffuse * sha + ambient * ao` +- SSS: `sss *= 0.25 + 0.75 * sha` -- SSS weakens but does not vanish in shadow +- Fog: complete lit+shadowed shading first, then `mix(col, fogColor, 1.0 - exp(-0.001*t*t))` +- Normal mapping: perturbed normals for lighting, geometric normals for shadow determination +- Reflection: `refSha = calcSoftShadow(pos + nor*0.01, reflect(rd, nor), 0.02, 8.0)` + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/shadow-techniques.md) diff --git a/skills/shader-dev/techniques/simulation-physics.md b/skills/shader-dev/techniques/simulation-physics.md new file mode 100644 index 0000000..1ffcef8 --- /dev/null +++ b/skills/shader-dev/techniques/simulation-physics.md @@ -0,0 +1,1542 @@ +- **Key**: Multi-pass rendering requires creating framebuffers and textures, switching the render target from screen to texture + +### WebGL2 Multi-Pass Rendering Complete Template + +Below is a complete standalone HTML template demonstrating how to set up WebGL2 double buffering (ping-pong) for physics simulation: + +**IMPORTANT: WebGL2 ping-pong core rule: The texture bound to the write-target framebuffer must never simultaneously serve as input for any iChannel.** Violating this rule causes undefined behavior (typically all-black/all-zero output). + +For simulations requiring "current frame" and "previous frame" two time steps (such as the wave equation), use **dual-channel encoding**: R channel stores current height, G channel stores previous frame height. This way only one buffer is read from (iChannel0 = currentBuf), writing to another buffer (nextBuf), avoiding read-write conflicts. + +```html + + +GPU Physics + + + + +``` + +**IMPORTANT: Common errors**: +1. **RGBA8 signed value truncation (fatal)**: Environments like SwiftShader that don't support `EXT_color_buffer_float` fall back to RGBA8 where values are clamped to [0,1]. Simulations requiring negative values (like the wave equation) will all zero out and produce a static image. **Must** use encode/decode functions: store as `v * 0.5 + 0.5`, read as `v * 2.0 - 1.0`, switching at runtime via `uniform int useFloatTex`. See the `encode()`/`decode()` functions in the template above +2. **Ping-pong read-write conflict (fatal)**: When rendering to a framebuffer, the texture bound to that framebuffer cannot simultaneously serve as input. The wave equation uses dual-channel encoding (R=current, G=previous) requiring only one input buffer; cloth/particle systems read getpos/getvel both from iChannel0 +3. **Cloth rendering must use world coordinate projection (fatal)**: The cloth Image Pass cannot use `uv * vec2(SIZX, SIZY)` to map screen UV directly to grid ID. It must iterate over mesh faces, project vertex world coordinates to screen space via `worldToScreen()`, and perform triangle rasterization +4. **Smoke brightness insufficient (fatal)**: Beer-Lambert absorption must be >=3.0, background color >=`vec3(0.06, 0.07, 0.10)`, smoke base color >=`vec3(0.35)`, add gamma correction `pow(col, vec3(0.85))`, source density >=3.0, density decay >=0.9995 +5. **GLSL reserved words**: `active`, `input`, `output`, `filter`, `sample`, `buffer`, `shared` cannot be used as variable names +6. **viewport/iResolution**: Buffer pass uses simulation resolution, Image pass uses screen resolution. Cloth Image Pass getpos/getvel must use `iSimResolution` +7. **GLSL type & math safety**: Cannot write `float / vec2`; `normalize(vec3(0))` produces NaN — check `length(v) > 0.0001` before calling +8. **GLSL nested functions forbidden**: Functions cannot be defined inside other functions +9. **JS variable declarations**: Ping-pong variables inside for loops must use `let`; in substeps, pass `iFrame` as `frame * substeps + substep` + +# GPU Physics Simulation Skill + +## Use Cases + +- Real-time physics simulation: waves, fluid smoke, cloth, rigid body collision, particle fluids +- Interactive physics effects: mouse force fields, ripples, pushing/pulling rigid bodies +- Scientific visualization: chaotic attractors, vortex dynamics, ship wave dispersion +- Iterative computations requiring "previous frame → next frame" state persistence + +## Core Principles + +The core paradigm of GPU physics simulation is **Buffer Feedback**: physical state is stored in texture buffers, each frame reads the previous frame's state → computes → writes back, with each pixel processed independently in parallel. + +### Key Mathematical Tools + +``` +Discrete Laplacian: ∇²f ≈ f(x+1,y) + f(x-1,y) + f(x,y+1) + f(x,y-1) - 4·f(x,y) +Semi-Lagrangian advection: f_new(x) = f_old(x - v·dt) +Spring force: F = k · (|Δx| - L₀) · normalize(Δx) +Damping force: F = c · dot(normalize(Δx), Δv) · normalize(Δx) +Vorticity confinement: curl = ∂v_x/∂y - ∂v_y/∂x +``` + +### Architecture Patterns + +| Layer | Responsibility | ShaderToy Implementation | +|-------|---------------|--------------------------| +| **State Storage** | Encode physical quantities into textures | Buffer RGBA channels | +| **Solver** | Read old state → compute forces → integrate → write new state | Buffer Pass (can be chained iteratively) | +| **Rendering** | Visualize physical state | Image Pass | + +## Implementation Steps + +### Step 1: Ping-Pong Double Buffering (Correct WebGL2 Implementation) + +**IMPORTANT: Key difference between ShaderToy and WebGL2**: In ShaderToy, Buffer A/B are two independent passes with separate write targets, so `iChannel0=self, iChannel1=other` doesn't conflict. But in WebGL2 with a single shader program doing ping-pong, the write-target texture cannot be read simultaneously. + +**Solution: Dual-channel encoding** — R channel stores current height, G channel stores previous frame height, requiring only one input buffer: + +```glsl +// WebGL2 Wave Equation Buffer Pass +// IMPORTANT: Only iChannel0 (reads currentBuf), writes to nextBuf (must be different!) +// IMPORTANT: encode/decode ensures signed values aren't truncated under RGBA8 (no float textures) + +uniform int useFloatTex; +float decode(float v) { return useFloatTex == 1 ? v : v * 2.0 - 1.0; } +float encode(float v) { return useFloatTex == 1 ? v : v * 0.5 + 0.5; } + +void main() { + vec2 uv = vUv; + vec2 texel = 1.0 / iResolution; + + vec2 raw = texture(iChannel0, uv).xy; + float current = decode(raw.x); + float previous = decode(raw.y); + + float left = decode(texture(iChannel0, uv - vec2(texel.x, 0.0)).x); + float right = decode(texture(iChannel0, uv + vec2(texel.x, 0.0)).x); + float down = decode(texture(iChannel0, uv - vec2(0.0, texel.y)).x); + float up = decode(texture(iChannel0, uv + vec2(0.0, texel.y)).x); + + float laplacian = left + right + down + up - 4.0 * current; + float next = 2.0 * current - previous + 0.25 * laplacian; + next *= 0.995; + next *= min(1.0, float(iFrame)); + + fragColor = vec4(encode(next), encode(current), 0.0, 1.0); +} +``` + +Corresponding JS render loop (binds only one input texture): +```js +const currentBuf = (frame % 2 === 0) ? bufA : bufB; +const nextBuf = (frame % 2 === 0) ? bufB : bufA; + +gl.bindFramebuffer(gl.FRAMEBUFFER, nextBuf.fb); // write to nextBuf +gl.activeTexture(gl.TEXTURE0); +gl.bindTexture(gl.TEXTURE_2D, currentBuf.tex); // read from currentBuf +gl.uniform1i(gl.getUniformLocation(progBuffer, 'iChannel0'), 0); +gl.uniform1i(gl.getUniformLocation(progBuffer, 'useFloatTex'), useFloat ? 1 : 0); +// IMPORTANT: Do NOT bind iChannel1 to nextBuf/otherBuf! +``` + +### Step 2: Interaction-Driven (External Force Injection) + +```glsl +// Insert external forces before the wave equation computation (add to next) +float force = 0.0; +if (iMouse.z > 0.0) +{ + vec2 fragCoord = vUv * iResolution; + force = smoothstep(4.5, 0.5, length(iMouse.xy - fragCoord)); +} +else +{ + // Procedural raindrops + vec2 fragCoord = vUv * iResolution; + float t = iTime * 2.0; + vec2 pos = fract(floor(t) * vec2(0.456665, 0.708618)) * iResolution; + float amp = 1.0 - step(0.05, fract(t)); + force = -amp * smoothstep(2.5, 0.5, length(pos - fragCoord)); +} + +// Add external force after wave equation +next += force; +``` + +### Step 3: Height Field Rendering (Image Pass) + +```glsl +// IMPORTANT: Image Pass also needs decode +uniform int useFloatTex; +float decode(float v) { return useFloatTex == 1 ? v : v * 2.0 - 1.0; } + +void main() +{ + vec2 uv = vUv; + vec2 texel = 1.0 / iResolution; + + float left = decode(texture(iChannel0, uv - vec2(texel.x, 0.0)).x); + float right = decode(texture(iChannel0, uv + vec2(texel.x, 0.0)).x); + float down = decode(texture(iChannel0, uv - vec2(0.0, texel.y)).x); + float up = decode(texture(iChannel0, uv + vec2(0.0, texel.y)).x); + + vec3 normal = normalize(vec3((right - left) * 8.0, (up - down) * 8.0, 1.0)); + + vec3 light = normalize(vec3(0.2, -0.5, 0.7)); + float diffuse = max(dot(normal, light), 0.0); + float spec = pow(max(-reflect(light, normal).z, 0.0), 32.0); + + vec3 waterTint = vec3(0.05, 0.15, 0.3); + vec3 color = waterTint * (0.6 + 0.5 * diffuse) + vec3(1.0) * spec * 0.6; + + fragColor = vec4(color, 1.0); +} +``` + +### Step 4: Chained Multi-Buffer Iteration (Fluid Solver) + +Buffer A/B/C share the solver from a Common pass, iterating 3 times per frame: +```glsl +// === Common Pass === +#define dt 0.15 +#define viscosityThreshold 0.64 +#define vorticityThreshold 0.25 + +vec4 fluidSolver(sampler2D field, vec2 uv, vec2 step, + vec4 mouse, vec4 prevMouse) +{ + float k = 0.2, s = k / dt; + vec4 c = textureLod(field, uv, 0.0); + vec4 fr = textureLod(field, uv + vec2(step.x, 0.0), 0.0); + vec4 fl = textureLod(field, uv - vec2(step.x, 0.0), 0.0); + vec4 ft = textureLod(field, uv + vec2(0.0, step.y), 0.0); + vec4 fd = textureLod(field, uv - vec2(0.0, step.y), 0.0); + + vec3 ddx = (fr - fl).xyz * 0.5; + vec3 ddy = (ft - fd).xyz * 0.5; + float divergence = ddx.x + ddy.y; + vec2 densityDiff = vec2(ddx.z, ddy.z); + + c.z -= dt * dot(vec3(densityDiff, divergence), c.xyz); + + vec2 laplacian = fr.xy + fl.xy + ft.xy + fd.xy - 4.0 * c.xy; + vec2 viscosity = viscosityThreshold * laplacian; + + vec2 densityInv = s * densityDiff; + vec2 uvHistory = uv - dt * c.xy * step; + c.xyw = textureLod(field, uvHistory, 0.0).xyw; + + vec2 extForce = vec2(0.0); + if (mouse.z > 1.0 && prevMouse.z > 1.0) + { + vec2 drag = clamp((mouse.xy - prevMouse.xy) * step * 600.0, -10.0, 10.0); + vec2 p = uv - mouse.xy * step; + extForce += 0.001 / dot(p, p) * drag; + } + + c.xy += dt * (viscosity - densityInv + extForce); + c.xy = max(vec2(0.0), abs(c.xy) - 5e-6) * sign(c.xy); + + // Vorticity confinement + c.w = (fd.x - ft.x + fr.y - fl.y); + vec2 vorticity = vec2(abs(ft.w) - abs(fd.w), abs(fl.w) - abs(fr.w)); + vorticity *= vorticityThreshold / (length(vorticity) + 1e-5) * c.w; + c.xy += vorticity; + + c.y *= smoothstep(0.5, 0.48, abs(uv.y - 0.5)); + c.x *= smoothstep(0.5, 0.49, abs(uv.x - 0.5)); + c = clamp(c, vec4(-24.0, -24.0, 0.5, -0.25), vec4(24.0, 24.0, 3.0, 0.25)); + return c; +} + +// === Buffer A / B / C === +void mainImage(out vec4 fragColor, in vec2 fragCoord) +{ + vec2 uv = fragCoord / iResolution.xy; + vec2 stepSize = 1.0 / iResolution.xy; + vec4 prevMouse = textureLod(iChannel0, vec2(0.0), 0.0); + fragColor = fluidSolver(iChannel0, uv, stepSize, iMouse, prevMouse); + if (fragCoord.y < 1.0) fragColor = iMouse; // store mouse state +} +``` + +### Step 5: Particle Data Layout (Cloth/N-Body) + +Texture regions are partitioned to store different attributes: +```glsl +#define SIZX 128.0 +#define SIZY 64.0 + +// IMPORTANT: Cloth/particle systems: getpos/getvel both read from iChannel0 (not iChannel1) +// Because iChannel0 = currentBuf (read-only), write target is nextBuf (separate buffer) +// IMPORTANT: Use +0.5 to sample texel centers (not +0.01) +vec3 getpos(vec2 id) { + return texture(iChannel0, (id + 0.5) / iResolution.xy).xyz; +} +vec3 getvel(vec2 id) { + return texture(iChannel0, (id + 0.5 + vec2(SIZX, 0.0)) / iResolution.xy).xyz; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) +{ + vec2 fc = floor(fragCoord); + vec2 c = fc; + c.x = fract(c.x / SIZX) * SIZX; + + vec3 pos = getpos(c); + vec3 vel = getvel(c); + // ... physics computation ... + fragColor = vec4(fc.x >= SIZX ? vel : pos, 0.0); +} +``` + +### Step 6: Spring-Damper Constraints + +```glsl +const float SPRING_K = 0.15; +const float DAMPER_C = 0.10; +const float GRAVITY = 0.0022; + +vec3 pos, vel, ovel; +vec2 c; + +void edge(vec2 dif) +{ + if ((dif + c).x < 0.0 || (dif + c).x >= SIZX || + (dif + c).y < 0.0 || (dif + c).y >= SIZY) return; + + float restLen = length(dif); + vec3 posdif = getpos(dif + c) - pos; + vec3 veldif = getvel(dif + c) - ovel; + + float plen = length(posdif); + if (plen < 0.0001) return; + vec3 dir = posdif / plen; + + vel += dir * clamp(plen - restLen, -1.0, 1.0) * SPRING_K; + vel += dir * dot(dir, veldif) * DAMPER_C; +} + +// Call 12 edges: 4 nearest neighbors + 4 diagonal + 4 skip +// edge(vec2(0,1)); edge(vec2(0,-1)); edge(vec2(1,0)); edge(vec2(-1,0)); +// edge(vec2(1,1)); edge(vec2(-1,-1)); +// edge(vec2(0,2)); edge(vec2(0,-2)); edge(vec2(2,0)); edge(vec2(-2,0)); +// edge(vec2(2,-2)); edge(vec2(-2,2)); +``` + +### Step 7: N-Body Vortex Particles (Biot-Savart) + +```glsl +#define N 20 +#define Nf float(N) +#define MARKERS 0.90 + +float STRENGTH = 1e3 * 0.25 / (1.0 - MARKERS) * sqrt(30.0 / Nf); +#define tex(i,j) texture(iChannel1, (vec2(i,j) + 0.5) / iResolution.xy) +#define W(i,j) tex(i, j + N).z + +void mainImage(out vec4 O, vec2 U) +{ + vec2 T = floor(U / Nf); + U = mod(U, Nf); + vec2 F = vec2(0.0); + + for (int j = 0; j < N; j++) + for (int i = 0; i < N; i++) + { + float w = W(i, j); + vec2 d = tex(i, j).xy - O.xy; + d = (fract(0.5 + d / iResolution.xy) - 0.5) * iResolution.xy; + float l = dot(d, d); + if (l > 1e-5) + F += vec2(-d.y, d.x) * w / l; + } + + O.zw = STRENGTH * F; + O.xy += O.zw * dt; + O.xy = mod(O.xy, iResolution.xy); +} +``` + +### Step 8: Global State Storage (Specific Pixel) + +```glsl +void mainImage(out vec4 fragColor, in vec2 fragCoord) +{ + // Pixel (0,0) stores global state (e.g., Lorenz attractor position) + if (floor(fragCoord) == vec2(0, 0)) + { + if (iFrame == 0) { + fragColor = vec4(0.1, 0.001, 0.0, 0.0); + } else { + vec3 state = texture(iChannel0, vec2(0.0)).xyz; + for (float i = 0.0; i < 96.0; i++) { + vec3 deriv; + deriv.x = 10.0 * (state.y - state.x); + deriv.y = state.x * (28.0 - state.z) - state.y; + deriv.z = state.x * state.y - 8.0/3.0 * state.z; + state += deriv * 0.016 * 0.2; + } + fragColor = vec4(state, 0.0); + } + return; + } + + // Other pixels: accumulate trajectory distance field + vec3 last = texture(iChannel0, vec2(0.0)).xyz; + float d = 1e6; + for (float i = 0.0; i < 96.0; i++) { + vec3 next = Integrate(last, 0.016 * 0.2); + d = min(d, dfLine(last.xz * 0.015, next.xz * 0.015, uv)); + last = next; + } + float c = 0.5 * smoothstep(1.0 / iResolution.y, 0.0, d); + vec3 prev = texture(iChannel0, fragCoord / iResolution.xy).rgb; + fragColor = vec4(vec3(c) + prev * 0.99, 0.0); +} +``` + +## Complete Code Template + +2D wave simulation: double buffering + mouse interaction + procedural raindrops + height field water surface rendering. + +**Ping-Pong setup**: bufA and bufB alternate; the shader only reads from iChannel0 (currentBuf) and writes to nextBuf. R=current height, G=previous frame height. + +```glsl +// === Buffer Pass (Wave Equation) === +// IMPORTANT: Only uses iChannel0 = currentBuf (read-only), writes to nextBuf +// IMPORTANT: encode/decode ensures signed values aren't truncated under RGBA8 (SwiftShader compatible) + +uniform int useFloatTex; +float decode(float v) { return useFloatTex == 1 ? v : v * 2.0 - 1.0; } +float encode(float v) { return useFloatTex == 1 ? v : v * 0.5 + 0.5; } + +#define DAMPING 0.995 +#define WAVE_SPEED 0.25 + +void mainImage(out vec4 fragColor, in vec2 fragCoord) +{ + vec2 uv = fragCoord / iResolution.xy; + vec2 texel = 1.0 / iResolution.xy; + + float current = decode(texture(iChannel0, uv).x); + float previous = decode(texture(iChannel0, uv).y); + + float left = decode(texture(iChannel0, uv - vec2(texel.x, 0.0)).x); + float right = decode(texture(iChannel0, uv + vec2(texel.x, 0.0)).x); + float down = decode(texture(iChannel0, uv - vec2(0.0, texel.y)).x); + float up = decode(texture(iChannel0, uv + vec2(0.0, texel.y)).x); + + float force = 0.0; + if (iMouse.z > 0.0) { + force = smoothstep(4.5, 0.5, length(iMouse.xy - fragCoord)); + } else { + float t = iTime * 2.0; + vec2 pos = fract(floor(t) * vec2(0.456665, 0.708618)) * iResolution.xy; + float amp = 1.0 - step(0.05, fract(t)); + force = -amp * smoothstep(2.5, 0.5, length(pos - fragCoord)); + } + + float laplacian = left + right + down + up - 4.0 * current; + float next = 2.0 * current - previous + WAVE_SPEED * laplacian; + next += force; + next *= DAMPING; + next *= min(1.0, float(iFrame)); + + fragColor = vec4(encode(next), encode(current), 0.0, 0.0); +} +``` + +```glsl +// === Image Pass === +// IMPORTANT: Also needs decode to correctly read signed height values + +uniform int useFloatTex; +float decode(float v) { return useFloatTex == 1 ? v : v * 2.0 - 1.0; } + +#define SPECULAR_POWER 32.0 + +void mainImage(out vec4 fragColor, in vec2 fragCoord) +{ + vec2 uv = fragCoord / iResolution.xy; + vec2 texel = 1.0 / iResolution.xy; + + float left = decode(texture(iChannel0, uv - vec2(texel.x, 0.0)).x); + float right = decode(texture(iChannel0, uv + vec2(texel.x, 0.0)).x); + float down = decode(texture(iChannel0, uv - vec2(0.0, texel.y)).x); + float up = decode(texture(iChannel0, uv + vec2(0.0, texel.y)).x); + + vec3 normal = normalize(vec3((right - left) * 8.0, (up - down) * 8.0, 1.0)); + + vec3 light = normalize(vec3(0.3, 0.6, 0.8)); + vec3 viewDir = vec3(0.0, 0.0, 1.0); + float diffuse = max(dot(normal, light), 0.0); + vec3 reflectDir = reflect(-light, normal); + float spec = pow(max(dot(reflectDir, viewDir), 0.0), SPECULAR_POWER); + + float h = decode(texture(iChannel0, uv).x); + vec3 deepColor = vec3(0.02, 0.06, 0.15); + vec3 shallowColor = vec3(0.05, 0.18, 0.30); + vec3 waterBase = mix(deepColor, shallowColor, clamp(abs(h) * 3.0, 0.0, 1.0)); + + float fresnel = pow(1.0 - max(dot(normal, viewDir), 0.0), 3.0); + vec3 skyColor = vec3(0.4, 0.55, 0.75); + vec3 color = waterBase * (0.6 + 0.5 * diffuse); + color = mix(color, skyColor, fresnel * 0.3); + color += vec3(1.0, 0.95, 0.85) * spec * 0.6; + + fragColor = vec4(color, 1.0); +} +``` + +## Common Variants + +### Variant 1: Euler Fluid (Smoke/Ink) + +Smoke/ink simulation requires a complete Buffer Pass (fluid solver) + Image Pass (volume rendering). Buffer stores xy=velocity, z=density, w=curl. + +```glsl +// === Buffer Pass (Fluid Solver) === +// Requires 3 chained buffer iterations (A→B→C→Image), each buffer executes the same fluidSolver +#define dt 0.15 +#define viscosityCoeff 0.64 +#define vorticityCoeff 0.25 + +vec4 fluidSolver(sampler2D field, vec2 uv, vec2 step, vec4 mouse, vec4 prevMouse) { + float k = 0.2, s = k / dt; + vec4 c = textureLod(field, uv, 0.0); + vec4 fr = textureLod(field, uv + vec2(step.x, 0.0), 0.0); + vec4 fl = textureLod(field, uv - vec2(step.x, 0.0), 0.0); + vec4 ft = textureLod(field, uv + vec2(0.0, step.y), 0.0); + vec4 fd = textureLod(field, uv - vec2(0.0, step.y), 0.0); + + vec3 ddx = (fr - fl).xyz * 0.5; + vec3 ddy = (ft - fd).xyz * 0.5; + float divergence = ddx.x + ddy.y; + vec2 densityDiff = vec2(ddx.z, ddy.z); + + c.z -= dt * dot(vec3(densityDiff, divergence), c.xyz); + + vec2 laplacian = fr.xy + fl.xy + ft.xy + fd.xy - 4.0 * c.xy; + vec2 viscosity = viscosityCoeff * laplacian; + vec2 densityInv = s * densityDiff; + + // Semi-Lagrangian advection + vec2 uvHistory = uv - dt * c.xy * step; + c.xyw = textureLod(field, uvHistory, 0.0).xyw; + + // Buoyancy (key for smoke: higher density means stronger upward force) + float buoyancy = 0.15 * c.z; + c.y += buoyancy * dt; + + // Wind force (horizontal offset) + c.x += 0.02 * sin(uv.y * 6.28 + float(iFrame) * 0.02) * c.z * dt; + + // Mouse/procedural source injection + vec2 extForce = vec2(0.0); + float densitySource = 0.0; + if (mouse.z > 1.0 && prevMouse.z > 1.0) { + vec2 drag = clamp((mouse.xy - prevMouse.xy) * step * 600.0, -10.0, 10.0); + vec2 p = uv - mouse.xy * step; + float influence = 0.001 / (dot(p, p) + 1e-6); + extForce += influence * drag; + densitySource += influence * 0.5; + } else { + // Procedural bottom smoke sources (multi-point + wide range, ensuring dense visibility) + float srcStrength = 0.0; + for (float si = -1.0; si <= 1.0; si += 1.0) { + float srcX = 0.5 + si * 0.12 + 0.08 * sin(float(iFrame) * 0.013 + si * 2.0); + vec2 srcPos = vec2(srcX, 0.06); + float d = length(uv - srcPos); + srcStrength += smoothstep(0.12, 0.0, d) * 3.5; + } + densitySource += srcStrength; + extForce.y += srcStrength * 0.4; + } + + c.xy += dt * (viscosity - densityInv + extForce); + c.z = max(c.z + densitySource * dt, 0.0); + + // Vorticity confinement (preserves smoke detail and curling structures) + c.w = (fd.x - ft.x + fr.y - fl.y); + vec2 vortGrad = vec2(abs(ft.w) - abs(fd.w), abs(fl.w) - abs(fr.w)); + vortGrad *= vorticityCoeff / (length(vortGrad) + 1e-5) * c.w; + c.xy += vortGrad; + + c.y *= smoothstep(0.5, 0.48, abs(uv.y - 0.5)); + c.x *= smoothstep(0.5, 0.49, abs(uv.x - 0.5)); + c.z *= 0.9995; // density decay (closer to 1.0 = denser and more persistent smoke) + c = clamp(c, vec4(-24.0, -24.0, 0.0, -0.25), vec4(24.0, 24.0, 5.0, 0.25)); + return c; +} + +void main() { + vec2 uv = vUv; + vec2 stepSize = 1.0 / iResolution; + vec4 prevMouse = textureLod(iChannel0, vec2(0.0), 0.0); + fragColor = fluidSolver(iChannel0, uv, stepSize, iMouse, prevMouse); + if (floor(vUv * iResolution).y < 1.0) fragColor = iMouse; +} +``` + +```glsl +// === Image Pass (Smoke Rendering) === +// Reads density (z channel) and velocity (xy channels) from buffer, renders dense layered smoke + light scattering +// IMPORTANT: Smoke brightness key: absorption coefficient must be large enough (>=3.0), background not too dark, lightTransmit must not over-attenuate + +void main() { + vec2 uv = vUv; + vec4 data = texture(iChannel0, uv); + float density = data.z; + vec2 vel = data.xy; + + // Multi-layer sampling for added depth (accumulate density from nearby pixels) + float layeredDensity = density; + vec2 texel = 1.0 / iResolution; + for (float i = 1.0; i <= 4.0; i += 1.0) { + float scale = i * 3.0; + layeredDensity += texture(iChannel0, uv + vec2(texel.x * scale, 0.0)).z * 0.4; + layeredDensity += texture(iChannel0, uv - vec2(texel.x * scale, 0.0)).z * 0.4; + layeredDensity += texture(iChannel0, uv + vec2(0.0, texel.y * scale)).z * 0.4; + layeredDensity += texture(iChannel0, uv - vec2(0.0, texel.y * scale)).z * 0.4; + } + layeredDensity /= 4.0; + + // Beer-Lambert absorption (denser regions are more opaque) + float absorption = 1.0 - exp(-layeredDensity * 3.5); + + // Light scattering: accumulate density from light direction for simple ray marching + vec2 lightDir2D = normalize(vec2(0.3, 1.0)); + float lightAccum = 0.0; + for (float s = 1.0; s <= 8.0; s += 1.0) { + vec2 sampleUV = uv + lightDir2D * texel * s * 5.0; + lightAccum += texture(iChannel0, sampleUV).z; + } + float lightTransmit = exp(-lightAccum * 0.25); + + // Velocity field drives color variation (faster flow regions are brighter) + float speed = length(vel); + + // Smoke color: gray-white tones, affected by lighting + vec3 smokeBase = mix(vec3(0.35, 0.32, 0.30), vec3(0.85, 0.82, 0.78), lightTransmit); + smokeBase += vec3(1.0, 0.85, 0.6) * lightTransmit * absorption * 0.5; + smokeBase += vec3(0.3, 0.2, 0.1) * speed * 3.0; + + // Background gradient (blue-gray, bright enough to contrast with smoke) + vec3 bg = mix(vec3(0.06, 0.07, 0.10), vec3(0.15, 0.18, 0.25), uv.y); + + vec3 col = mix(bg, smokeBase, absorption); + + // Bottom light source glow + float glow = smoothstep(0.3, 0.0, uv.y) * 0.4; + col += vec3(1.0, 0.6, 0.2) * glow * (0.5 + 0.5 * absorption); + + // Gamma correction to ensure smoke visibility + col = pow(col, vec3(0.85)); + + fragColor = vec4(col, 1.0); +} +``` + +Smoke simulation requires 3 chained buffer iterations (same fluidSolver) for enhanced convergence. JS side creates bufA/bufB/bufC, executing A→B→C→Image each frame. + +### Variant 2: Cloth Simulation (Mass-Spring-Damper) + +Cloth simulation requires 2 buffers for ping-pong alternating read/write, with a JS render loop using a for loop to execute multiple substeps (e.g., 4 steps). Data structure: +- Left half of texture [0, SIZX) stores position xyz +- Right half of texture [SIZX, 2*SIZX) stores velocity xyz +- **Note**: When using substep loops, buffer variables in the render function must use `let` to allow reassignment within the loop +- **Key**: Image Pass `getpos`/`getvel` functions must use the simulation resolution (`iSimResolution`) for UV calculation, not the screen resolution + +```glsl +// WebGL2-adapted cloth simulation Buffer Pass +// IMPORTANT: getpos/getvel both read from iChannel0! iChannel0 = currentBuf (read-only), writes to nextBuf + +#define SIZX 128.0 +#define SIZY 64.0 +const float SPRING_K = 0.15; +const float DAMPER_C = 0.10; +const float GRAVITY = 0.0022; + +vec3 pos, vel, ovel; +vec2 c; + +vec3 getpos(vec2 id) { + return texture(iChannel0, (id + 0.5) / iResolution.xy).xyz; +} +vec3 getvel(vec2 id) { + return texture(iChannel0, (id + 0.5 + vec2(SIZX, 0.0)) / iResolution.xy).xyz; +} + +void edge(vec2 dif) { + if ((dif + c).x < 0.0 || (dif + c).x >= SIZX || + (dif + c).y < 0.0 || (dif + c).y >= SIZY) return; + vec3 posdif = getpos(dif + c) - pos; + vec3 veldif = getvel(dif + c) - ovel; + float restLen = length(dif); + float plen = length(posdif); + if (plen < 0.0001) return; + vec3 dir = posdif / plen; + vel += dir * clamp(plen - restLen, -1.0, 1.0) * SPRING_K; + vel += dir * dot(dir, veldif) * DAMPER_C; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 fc = floor(fragCoord); + c = fc; + c.x = fract(c.x / SIZX) * SIZX; + + // iFrame should pass the global frame count: frame * SUBSTEPS + substep + if (iFrame < 4) { + vec2 p = vec2(c.x / SIZX, c.y / SIZY); + vec3 initialPos = vec3(p.x * 1.6 - 0.3, -p.y * 0.8 + 0.6, 0.0); + fragColor = vec4(fc.x >= SIZX ? vec3(0.0) : initialPos, 0.0); + return; + } + + pos = getpos(c); + vel = getvel(c); + ovel = vel; + + edge(vec2(0,1)); edge(vec2(0,-1)); edge(vec2(1,0)); edge(vec2(-1,0)); + edge(vec2(1,1)); edge(vec2(-1,-1)); + edge(vec2(0,2)); edge(vec2(0,-2)); edge(vec2(2,0)); edge(vec2(-2,0)); + + vel.y -= GRAVITY; + + vec3 ballPos = vec3(0.35, 0.3, 0.0); + float ballRadius = 0.15; + vec3 toBall = pos - ballPos; + float distToBall = length(toBall); + if (distToBall < ballRadius && distToBall > 0.0001) { + vec3 pushDir = toBall / distToBall; + pos = ballPos + pushDir * ballRadius; + vel -= pushDir * dot(pushDir, vel); + } + + if (c.y == 0.0) { + pos = vec3(fc.x * 0.85 / SIZX, 0.0, 0.0); + vel = vec3(0.0); + } + + pos += vel; + + fragColor = vec4(fc.x >= SIZX ? vel : pos, 0.0); +} +``` + +#### Cloth Rendering Pass (Image Pass) Complete Template + +**IMPORTANT: Cloth rendering core principle**: After physics simulation, cloth particle world positions (pos.xy) will deviate from their initial grid positions (due to gravity, collisions, etc.). The Image Pass must render based on particles' **actual world positions** projected to the screen — you cannot use `uv * vec2(SIZX, SIZY)` to directly map screen UV to grid ID (that would produce scattered dots/fragments rather than a continuous cloth surface). + +Correct approach: iterate over all cloth mesh cells, project each cell's 4 vertex world coordinates to screen space, determine if the current pixel falls within that quad, then interpolate shading. + +```glsl +// IMPORTANT: Key: must pass additional uniform vec2 iSimResolution (simulation resolution) +// getpos/getvel use iSimResolution, not iResolution +// Rendering method: iterate cloth mesh, project world coordinates to screen coordinates + +#define SIZX 128.0 +#define SIZY 64.0 + +in vec2 vUv; +out vec4 fragColor; + +uniform sampler2D iChannel0; +uniform vec2 iResolution; +uniform vec2 iSimResolution; +uniform float iTime; + +vec3 getpos(vec2 id) { + return texture(iChannel0, (id + 0.5) / iSimResolution).xyz; +} +vec3 getvel(vec2 id) { + return texture(iChannel0, (id + 0.5 + vec2(SIZX, 0.0)) / iSimResolution).xyz; +} + +vec2 worldToScreen(vec3 p) { + return vec2(p.x * 0.5 + 0.5, 1.0 - (p.y * 0.5 + 0.5)); +} + +vec3 calcNormal(vec2 cell) { + vec3 pL = getpos(vec2(max(cell.x - 1.0, 0.0), cell.y)); + vec3 pR = getpos(vec2(min(cell.x + 1.0, SIZX - 1.0), cell.y)); + vec3 pD = getpos(vec2(cell.x, max(cell.y - 1.0, 0.0))); + vec3 pU = getpos(vec2(cell.x, min(cell.y + 1.0, SIZY - 1.0))); + vec3 tanX = pR - pL; + vec3 tanY = pU - pD; + vec3 normal = cross(tanX, tanY); + float nlen = length(normal); + if (nlen < 0.0001) return vec3(0.0, 0.0, -1.0); + normal /= nlen; + if (normal.z > 0.0) normal = -normal; + return normal; +} + +float cross2d(vec2 a, vec2 b) { return a.x * b.y - a.y * b.x; } + +bool pointInTriangle(vec2 p, vec2 a, vec2 b, vec2 c, out vec3 bary) { + float d00 = dot(b - a, b - a); + float d01 = dot(b - a, c - a); + float d11 = dot(c - a, c - a); + float d20 = dot(p - a, b - a); + float d21 = dot(p - a, c - a); + float denom = d00 * d11 - d01 * d01; + if (abs(denom) < 1e-10) return false; + float v = (d11 * d20 - d01 * d21) / denom; + float w = (d00 * d21 - d01 * d20) / denom; + float u = 1.0 - v - w; + bary = vec3(u, v, w); + return u >= -0.01 && v >= -0.01 && w >= -0.01; +} + +void main() { + vec2 uv = vUv; + vec2 fragCoord = vUv * iResolution; + + vec3 bgTop = vec3(0.05, 0.08, 0.15); + vec3 bgBot = vec3(0.02, 0.03, 0.08); + vec3 bg = mix(bgBot, bgTop, uv.y); + vec3 col = bg; + float closestZ = 1e6; + + vec3 ballPos = vec3(0.35 + sin(iTime * 0.6) * 0.15, 0.3 + cos(iTime * 0.4) * 0.1, 0.0); + float ballRadius = 0.12; + vec2 ballScreen = worldToScreen(ballPos); + float ballDist = length(uv - ballScreen); + if (ballDist < ballRadius * 0.6) { + float shade = smoothstep(ballRadius * 0.6, ballRadius * 0.15, ballDist); + vec3 ballColor = vec3(0.95, 0.35, 0.2); + vec2 bnXY = (uv - ballScreen) / (ballRadius * 0.6); + float bnZ = sqrt(max(0.0, 1.0 - dot(bnXY, bnXY))); + vec3 bn = normalize(vec3(bnXY, bnZ)); + float bdiff = max(dot(bn, normalize(vec3(0.5, 0.8, 1.0))), 0.0); + float bspec = pow(max(dot(normalize(bn + normalize(vec3(0.5, 0.8, 1.0))), vec3(0.0, 0.0, 1.0)), 0.0), 32.0); + col = ballColor * (0.3 + 0.7 * bdiff) + vec3(1.0) * bspec * 0.4; + closestZ = ballPos.z - ballRadius; + } + + for (float cy = 0.0; cy < SIZY - 1.0; cy += 1.0) { + for (float cx = 0.0; cx < SIZX - 1.0; cx += 1.0) { + vec3 p00 = getpos(vec2(cx, cy)); + vec3 p10 = getpos(vec2(cx + 1.0, cy)); + vec3 p01 = getpos(vec2(cx, cy + 1.0)); + vec3 p11 = getpos(vec2(cx + 1.0, cy + 1.0)); + + vec2 s00 = worldToScreen(p00); + vec2 s10 = worldToScreen(p10); + vec2 s01 = worldToScreen(p01); + vec2 s11 = worldToScreen(p11); + + vec2 bboxMin = min(min(s00, s10), min(s01, s11)); + vec2 bboxMax = max(max(s00, s10), max(s01, s11)); + if (uv.x < bboxMin.x - 0.01 || uv.x > bboxMax.x + 0.01 || + uv.y < bboxMin.y - 0.01 || uv.y > bboxMax.y + 0.01) continue; + + vec3 bary; + vec2 cellId = vec2(cx, cy); + bool hit = false; + float interpZ = 0.0; + + if (pointInTriangle(uv, s00, s10, s01, bary)) { + hit = true; + interpZ = bary.x * p00.z + bary.y * p10.z + bary.z * p01.z; + } else if (pointInTriangle(uv, s10, s11, s01, bary)) { + hit = true; + interpZ = bary.x * p10.z + bary.y * p11.z + bary.z * p01.z; + } + + if (hit && interpZ < closestZ) { + closestZ = interpZ; + vec3 normal = calcNormal(cellId); + vec3 lightDir = normalize(vec3(0.5, 0.8, 1.0)); + float diff = max(dot(normal, lightDir), 0.0); + float diffBack = max(dot(-normal, lightDir), 0.0); + vec3 halfDir = normalize(lightDir + vec3(0.0, 0.0, 1.0)); + float spec = pow(max(dot(normal, halfDir), 0.0), 32.0); + + float stretch = length(getvel(cellId)); + vec3 clothColor1 = vec3(0.25, 0.55, 0.95); + vec3 clothColor2 = vec3(0.95, 0.35, 0.45); + vec3 clothColor = mix(clothColor1, clothColor2, clamp(stretch * 10.0, 0.0, 1.0)); + + vec2 gridFrac = fract(vec2(cx, cy) * 0.125); + float checker = step(0.5, fract(gridFrac.x + gridFrac.y)); + clothColor *= 0.85 + 0.15 * checker; + + col = clothColor * (0.3 + 0.6 * diff + 0.25 * diffBack) + vec3(1.0) * spec * 0.35; + } + } + } + + fragColor = vec4(col, 1.0); +} +``` + +**IMPORTANT: Cloth rendering performance note**: The above template uses a double loop to iterate all mesh faces for triangle rasterization. For a 128x64 mesh this is about 8000 quads per frame. If GPU performance is insufficient, reduce mesh resolution (e.g., SIZX=64, SIZY=32) or use `texelFetch` instead of `texture` for speed. Another approach is to partition the cloth into blocks (e.g., 4x4), each with an independent bounding box for early culling. + +#### Cloth Simulation Complete HTML Template (Multi-Substep Iteration) + +**IMPORTANT: Key notes (must-read for cloth template)**: +1. **No read-write conflict**: In the Buffer Pass, iChannel0 is bound to currentBuf (read-only), the write target is nextBuf (separate buffer). getpos/getvel both read from iChannel0 +2. **iSimResolution uniform**: Image Pass must have `uniform vec2 iSimResolution` passing `(SIM_W, SIM_H)`, and `getpos`/`getvel` internally use `iSimResolution` for UV calculation +3. **iFrame value passing**: In substep loops, iFrame should pass `frame * SUBSTEPS + substep`, ensuring the initialization condition `iFrame < SUBSTEPS` only triggers on the first frame +4. **Substeps use 2-buffer ping-pong + JS for loop**: Do not use 4 buffers; use 2 buffers alternating at the JS level + +```html + + +GPU Cloth Simulation + + + + +``` + +### Variant 3: Rigid Body Physics Engine (Box2D-lite on GPU) + +```glsl +// Structured memory addressing: struct mapped to consecutive pixels +int bodyAddress(int b_id) { + return pixel_count_of_Globals + pixel_count_of_Body * b_id; +} +Body loadBody(sampler2D buff, int b_id) { + int addr = bodyAddress(b_id); + vec4 d0 = texelFetch(buff, address2D(res, addr), 0); + vec4 d1 = texelFetch(buff, address2D(res, addr+1), 0); + b.pos = d0.xy; b.vel = d0.zw; + b.ang = d1.x; b.ang_vel = d1.y; +} + +// Contact impulse solver +float v_n = dot(dv, contact.normal); +float dp_n = contact.mass_n * (-v_n + contact.bias); +dp_n = max(0.0, dp_n); +body.vel += body.inv_mass * dp_n * contact.normal; +``` + +### Variant 4: N-Body Vortex Particles + +```glsl +// Biot-Savart kernel: v = w * (-dy, dx) / |d|² +for (int j = 0; j < N; j++) + for (int i = 0; i < N; i++) { + float w = W(i, j); + vec2 d = tex(i, j).xy - pos; + d = (fract(0.5 + d / res) - 0.5) * res; // periodic boundary + float l = dot(d, d); + if (l > 1e-5) F += vec2(-d.y, d.x) * w / l; + } +``` + +### Variant 5: 3D SPH Particle Fluid + +```glsl +// 2D texture mapping for 3D grid +vec2 dim2from3(vec3 p3d) { + float ny = floor(p3d.z / SCALE.x); + float nx = floor(p3d.z) - ny * SCALE.x; + return vec2(nx, ny) * size3d.xy + p3d.xy; +} + +// SPH pressure force + friction + surface tension +float pressure = max(rho / rest_density - 1.0, 0.0); +float SPH_F = force_coef_a * GD(d, 1.5) * pressure; +float Friction = 0.45 * dot(dir, dvel) * GD(d, 1.5); +float F = surface_tension * GD(d, surface_tension_rad); +p.force += force_k * dir * (F + SPH_F + Friction) * irho / rest_density; +``` + +## Performance & Composition + +### Performance Tips +- Use `texelFetch` instead of `texture` to skip filtering; precompute `1.0/iResolution.xy` +- N-Body: limit N to 20~30; passive marker particles (90%) skip force computation +- Cloth multi-substep: use 2 buffers + JS for loop (do not use 4-buffer chain) +- Adaptive precision: use larger time steps for distant regions +- Data packing: bit operations for compression (5-bit exponent + 3x9-bit components) +- Stability: `clamp` to prevent explosion, `smoothstep` for soft boundaries, damping 0.95~0.999 + +### Composition Patterns +- **Physics + post-processing**: wave refraction/caustics, fluid advection ink coloring, cloth ray tracing +- **Physics + SDF rendering**: `sdBox`/`length-radius` to render rigid bodies/particles +- **Physics + volume rendering**: density field trilinear interpolation → ray marching → lighting + shadows +- **Multi-system coupling**: fluid driving rigid bodies, cloth collision bodies, particle↔field mutual driving (SPH/Biot-Savart) +- **Physics + audio**: spectrum energy mapped as external force, low frequency drives large scale, high frequency drives small scale + +## Further Reading + +Full step-by-step tutorial, mathematical derivations, and advanced usage in [reference](../reference/simulation-physics.md) diff --git a/skills/shader-dev/techniques/sound-synthesis.md b/skills/shader-dev/techniques/sound-synthesis.md new file mode 100644 index 0000000..6ae13c2 --- /dev/null +++ b/skills/shader-dev/techniques/sound-synthesis.md @@ -0,0 +1,490 @@ +**IMPORTANT - GLSL ES 3.00 Critical Rules**: +1. **Type strictness**: `int` and `float` cannot be mixed directly; array indices must be of `int` type +2. **Reserved words**: `sample` is a reserved word in GLSL ES 3.00; it cannot be used as a variable name +3. **Constant arrays**: Must explicitly specify size when declaring, e.g., `const float ARR[4] = float[4](1.,2.,3.,4.);` +4. **Integer division**: In GLSL ES 3.00, `1/2` evaluates to 0 (integer division); must use `1.0/2.0` or `float(1)/float(2)` + +# Sound Synthesis (Procedural Audio) + +## Use Cases +- Generate procedural audio using `mainSound()` in ShaderToy +- Synthesize melodies, chords, rhythm patterns, and complete music +- Synthesize instrument timbres: piano, bass, acid synth, percussion +- Implement audio effects: delay, reverb, distortion, filters +- Pure mathematical audio generation without external samples + +## Core Principles + +ShaderToy sound shader four-layer architecture: + +1. **Oscillator layer**: `sin(2π·f·t)`, layering harmonics or FM modulation to build timbre +2. **Envelope layer**: `exp(-rate·t)` + `smoothstep` attack, simulating strike→decay +3. **Sequencer layer**: Macro definitions / array lookup / hash pseudo-random for arranging melodies +4. **Effects layer**: Reverb, delay, distortion, filters, and other post-processing + +Key formulas: +- MIDI → frequency: `f = 440.0 × 2^((n - 69) / 12)` +- Sine oscillator: `y = sin(2π × freq × time)` +- Exponential decay: `env = exp(-decay_rate × time)` +- FM modulation: `y = sin(2π × f_c × t + depth × sin(2π × f_m × t))` + +## Implementation Steps + +### Step 1: mainSound Entry Framework +```glsl +#define TAU 6.28318530718 +#define BPM 120.0 +#define SPB (60.0 / BPM) + +vec2 mainSound(int samp, float time) { + vec2 audio = vec2(0.0); + // Layer each instrument/track + audio *= 0.5 * smoothstep(0.0, 0.5, time); // Master volume + pop prevention + return clamp(audio, -1.0, 1.0); +} +``` + +### Step 2: MIDI Note to Frequency +```glsl +float noteFreq(float note) { + return 440.0 * pow(2.0, (note - 69.0) / 12.0); +} +``` + +### Step 3: Basic Oscillators +```glsl +float osc_sin(float t) { return sin(TAU * t); } +float osc_saw(float t) { return fract(t) * 2.0 - 1.0; } +float osc_sqr(float t) { return step(fract(t), 0.5) * 2.0 - 1.0; } +float osc_tri(float t) { return abs(fract(t) - 0.5) * 4.0 - 1.0; } +``` + +### Step 4: Additive Synthesis Instrument +```glsl +// Layer harmonics to build timbre; higher harmonics decay faster +float instrument_additive(float freq, float t) { + float y = 0.0; + y += 0.50 * sin(TAU * 1.00 * freq * t) * exp(-0.0015 * 1.0 * freq * t); + y += 0.30 * sin(TAU * 2.01 * freq * t) * exp(-0.0015 * 2.0 * freq * t); + y += 0.20 * sin(TAU * 4.01 * freq * t) * exp(-0.0015 * 4.0 * freq * t); + y += 0.1 * y * y * y; // Nonlinear waveshaping + y *= 0.9 + 0.1 * cos(40.0 * t); // Tremolo + y *= smoothstep(0.0, 0.01, t); // Smooth attack + return y; +} +``` + +### Step 5: FM Synthesis Instrument +```glsl +// FM electric piano (stereo) +vec2 fm_epiano(float freq, float t) { + vec2 f0 = vec2(freq * 0.998, freq * 1.002); // Stereo micro-detuning + // "Glass" layer - high-frequency FM, metallic attack + vec2 glass = sin(TAU * (f0 + 3.0) * t + + sin(TAU * 14.0 * f0 * t) * exp(-30.0 * t) + ) * exp(-4.0 * t); + glass = sin(glass); + // "Body" layer - low-frequency FM, warm sustained tone + vec2 body = sin(TAU * f0 * t + + sin(TAU * f0 * t) * exp(-0.5 * t) * pow(440.0 / f0.x, 0.5) + ) * exp(-t); + return (glass + body) * smoothstep(0.0, 0.001, t) * 0.1; +} + +// FM generic instrument (struct parameterized) +struct Instr { + float att, fo, vibe, vphas, phas, dtun; +}; + +float fm_instrument(float freq, float t, float beatTime, Instr ins) { + float f = freq - beatTime * ins.dtun; + float phase = f * t * TAU; + float vibrato = cos(beatTime * ins.vibe * 3.14159 / 8.0 + ins.vphas * 1.5708); + float fm = sin(phase + vibrato * sin(phase * ins.phas)); + float env = exp(-beatTime * ins.fo) * (1.0 - exp(-beatTime * ins.att)); + return fm * env * (1.0 - beatTime * 0.125); +} +``` + +### Step 6: Percussion Synthesis +```glsl +float hash(float p) { + p = fract(p * 0.1031); p *= p + 33.33; p *= p + p; return fract(p); +} + +// 909 kick drum: frequency sweep + noise click +float kick(float t) { + float phase = TAU * (60.0 * t - 512.0 * 0.01 * exp(-t / 0.01)); + float body = sin(phase) * smoothstep(0.3, 0.0, t) * 1.5; + float click = sin(TAU * 8000.0 * fract(t)) * hash(t * 2000.0) + * smoothstep(0.007, 0.0, t); + return body + click; +} + +// Hi-hat: noise + exponential decay. decay: 5.0=open, 15.0=closed +float hihat(float t, float decay) { + float noise = hash(floor(t * 44100.0)) * 2.0 - 1.0; + return noise * exp(-decay * t) * smoothstep(0.0, 0.02, t); +} + +// Clap/snare +float clap(float t) { + float noise = hash(floor(t * 44100.0)) * 2.0 - 1.0; + return noise * smoothstep(0.1, 0.0, t); +} +``` + +### Step 7: Note Sequence Arrangement +```glsl +// === Method A: D() macro accumulation (good for handwritten melodies) === +#define D(duration, note) b += float(duration); if(t > b) { x = b; n = float(note); } + +float melody_macro(float time) { + float t = time / 0.18; + float n = 0.0, b = 0.0, x = 0.0; + D(10,71) D(2,76) D(3,79) D(1,78) D(2,76) D(4,83) D(2,81) D(6,78) + float freq = noteFreq(n); + float noteTime = 0.18 * (t - x); + return instrument_additive(freq, noteTime); +} + +// === Method B: Array lookup (good for complex arrangements) === +// NOTE: Array indices must be int type in GLSL ES 3.00 +const float NOTES[16] = float[16]( + 60., 62., 64., 65., 67., 69., 71., 72., + 60., 64., 67., 72., 65., 69., 64., 60. +); + +float melody_array(float time, float bpm) { + float beat = time * bpm / 60.0; + int idx = int(mod(beat, 16.0)); // IMPORTANT: Must use int() conversion + float noteTime = fract(beat); + float freq = noteFreq(NOTES[idx]); + return instrument_additive(freq, noteTime * 60.0 / bpm); +} + +// === Method C: Hash pseudo-random (good for algorithmic composition) === +float nse(float x) { return fract(sin(x * 110.082) * 19871.8972); } + +float scale_filter(float note) { + float n2 = mod(note, 12.0); + if (n2==1.||n2==3.||n2==6.||n2==8.||n2==10.) return -100.0; + return note; +} + +float melody_random(float time, float bpm) { + float beat = time * bpm / 60.0; + float note = 48.0 + floor(nse(floor(beat)) * 24.0); + note = scale_filter(note); + return instrument_additive(noteFreq(note), fract(beat) * 60.0 / bpm); +} +``` + +### Step 8: Chord Construction +```glsl +vec2 chord(float time, float root, float isMinor) { + vec2 result = vec2(0.0); + float bass = root - 24.0; + result += fm_epiano(noteFreq(bass), time, 2.0); + result += fm_epiano(noteFreq(root), time - SPB * 0.5, 1.25); + result += fm_epiano(noteFreq(root + 4.0 - isMinor), time - SPB, 1.5); // Third + result += fm_epiano(noteFreq(root + 7.0), time - SPB * 0.5, 1.25); // Fifth + result += fm_epiano(noteFreq(root + 11.0 - isMinor), time - SPB, 1.5); // Seventh + result += fm_epiano(noteFreq(root + 14.0), time - SPB, 1.5); // Ninth + return result; +} +``` + +### Step 9: Delay and Reverb +```glsl +// Multi-tap echo +// NOTE: "sample" is a reserved word in GLSL ES 3.00; use "samp" instead +vec2 echo_reverb(float time) { + vec2 tot = vec2(0.0); + float hh = 1.0; + for (int i = 0; i < 6; i++) { + float h = float(i) / 5.0; + float samp = get_instrument_sample(time - 0.7 * h); + tot += samp * vec2(0.5 + 0.1 * h, 0.5 - 0.1 * h) * hh; + hh *= 0.5; + } + return tot; +} + +// Ping-pong stereo delay +vec2 pingpong_delay(float time) { + vec2 mx = get_stereo_sample(time) * 0.5; + float ec = 0.4, fb = 0.6, dt = 0.222; + float et = dt; + mx += get_stereo_sample(time - et) * ec * vec2(1.0, 0.5); ec *= fb; et += dt; + mx += get_stereo_sample(time - et) * ec * vec2(0.5, 1.0); ec *= fb; et += dt; + mx += get_stereo_sample(time - et) * ec * vec2(1.0, 0.5); ec *= fb; et += dt; + mx += get_stereo_sample(time - et) * ec * vec2(0.5, 1.0); ec *= fb; et += dt; + return mx; +} +``` + +### Step 10: Beat and Arrangement Structure +```glsl +vec2 mainSound(int samp, float time) { + vec2 audio = vec2(0.0); + float beat = time * BPM / 60.0; + float bar = beat / 4.0; + + // Kick (every beat) + hi-hat (every half beat) + melody + float kickTime = mod(time, SPB); + audio += vec2(kick(kickTime) * 0.5); + float hatTime = mod(time, SPB * 0.5); + audio += vec2(hihat(hatTime, 15.0) * 0.15); + audio += vec2(melody_array(time, BPM)) * 0.3; + + // Arrangement: smoothstep controls intro/outro + audio *= smoothstep(0.0, 4.0, bar); // Fade in over first 4 bars + audio *= 0.35 * smoothstep(0.0, 0.5, time); + // IMPORTANT: Array indices must be int type + // float idx = mod(beat, 16.0); // WRONG: float cannot be used as index + int idx = int(mod(beat, 16.0)); // CORRECT: int(mod(...)) conversion + return clamp(audio, -1.0, 1.0); +} +``` + +## Complete Code Template + +Can be pasted directly into the ShaderToy Sound tab to run. Includes FM piano melody, kick drum rhythm, and ping-pong delay. + +```glsl +// === Sound Synthesis Complete Template === +#define TAU 6.28318530718 +#define BPM 130.0 +#define SPB (60.0 / BPM) +#define NUM_HARMONICS 4 +#define ECHO_TAPS 4 +#define ECHO_DELAY 0.18 +#define ECHO_DECAY 0.45 + +float noteFreq(float note) { + return 440.0 * pow(2.0, (note - 69.0) / 12.0); +} + +float hash11(float p) { + p = fract(p * 0.1031); p *= p + 33.33; p *= p + p; return fract(p); +} + +float osc_tri(float t) { return abs(fract(t) - 0.5) * 4.0 - 1.0; } + +float instrument(float freq, float t) { + float y = 0.0; + for (int i = 1; i <= NUM_HARMONICS; i++) { + float h = float(i); + float amp = 0.6 / h; + float decay = 0.002 * h * freq; + y += amp * sin(TAU * h * 1.003 * freq * t) * exp(-decay * t); + } + y += 0.15 * y * y * y; + y *= 0.9 + 0.1 * cos(35.0 * t); + y *= smoothstep(0.0, 0.008, t); + return y; +} + +vec2 epiano(float freq, float t) { + vec2 f0 = vec2(freq * 0.998, freq * 1.002); + vec2 glass = sin(TAU * (f0 + 3.0) * t + + sin(TAU * 14.0 * f0 * t) * exp(-30.0 * t) + ) * exp(-4.0 * t); + glass = sin(glass); + vec2 body = sin(TAU * f0 * t + + sin(TAU * f0 * t) * exp(-0.5 * t) * pow(440.0 / max(f0.x, 1.0), 0.5) + ) * exp(-t); + return (glass + body) * smoothstep(0.0, 0.001, t) * 0.12; +} + +float kick(float t) { + float df = 512.0, dftime = 0.01, freq = 60.0; + float phase = TAU * (freq * t - df * dftime * exp(-t / dftime)); + float body = sin(phase) * smoothstep(0.3, 0.0, t) * 1.5; + float click = sin(TAU * 8000.0 * fract(t)) * hash11(t * 2048.0) + * smoothstep(0.007, 0.0, t); + return body + click; +} + +float hihat(float t) { + float noise = hash11(floor(t * 44100.0)) * 2.0 - 1.0; + return noise * exp(-15.0 * t) * smoothstep(0.0, 0.002, t); +} + +const float MELODY[16] = float[16]( + 67., 67., 72., 71., 69., 67., 64., 64., + 65., 65., 69., 67., 67., 65., 64., 62. +); + +const float BASS[4] = float[4](43., 48., 45., 41.); + +vec2 mainSound(int samp, float time) { + time = mod(time, 32.0 * SPB * 4.0); + vec2 audio = vec2(0.0); + float beat = time / SPB; + float bar = beat / 4.0; + + // Melody + { int idx = int(mod(beat, 16.0)); + float noteTime = fract(beat) * SPB; + audio += vec2(instrument(noteFreq(MELODY[idx]), noteTime) * 0.25); } + + // Bass + { int idx = int(mod(bar, 4.0)); + float noteTime = fract(bar) * SPB * 4.0; + float freq = noteFreq(BASS[idx]); + audio += vec2(osc_tri(freq * noteTime) * exp(-1.5 * noteTime) + * smoothstep(0.0, 0.01, noteTime) * 0.3); } + + // Kick (every beat) + sidechain compression + { float kt = mod(time, SPB); + float k = kick(kt) * 0.4; + audio *= min(1.0, kt * 6.0 / SPB); + audio += vec2(k); } + + // Hi-hat (every half beat, panned right) + { float ht = mod(time, SPB * 0.5); + audio += vec2(0.4, 0.6) * hihat(ht) * 0.12; } + + // Ping-pong delay (melody) + { float ec = 0.3; + for (int i = 1; i <= ECHO_TAPS; i++) { + float dt = ECHO_DELAY * float(i); + int idx = int(mod((time - dt) / SPB, 16.0)); + float nt = fract((time - dt) / SPB) * SPB; + float echoed = instrument(noteFreq(MELODY[idx]), nt) * 0.25 * ec; + if (i % 2 == 0) audio += vec2(0.3, 1.0) * echoed; + else audio += vec2(1.0, 0.3) * echoed; + ec *= ECHO_DECAY; + } } + + audio *= 0.4 * smoothstep(0.0, 2.0, time); + return clamp(audio, -1.0, 1.0); +} +``` + +## Common Variants + +### Variant 1: Subtractive Synthesis / TB-303 Acid Synth +Sawtooth wave through resonant low-pass filter, cutoff frequency modulated by envelope to produce the "wow" sound. +```glsl +#define NSPC 128 +float lpf_response(float h, float cutoff, float reso) { + cutoff -= 20.0; + float df = max(h - cutoff, 0.0); + float df2 = abs(h - cutoff); + return exp(-0.005 * df * df) * 0.5 + exp(df2 * df2 * -0.1) * reso; +} + +vec2 acid_synth(float freq, float noteTime) { + vec2 v = vec2(0.0); + float cutoff = exp(noteTime * -1.5) * 50.0 + 10.0; + float sqr = step(0.5, fract(noteTime * 4.5)); + for (int i = 0; i < NSPC; i++) { + float h = float(i + 1); + float inten = 1.0 / h; + inten = mix(inten, inten * mod(h, 2.0), sqr); + inten *= lpf_response(h, cutoff, 2.2); + v.x += inten * sin((TAU + 0.01) * noteTime * freq * h); + v.y += inten * sin(TAU * noteTime * freq * h); + } + float amp = smoothstep(0.05, 0.0, abs(noteTime - 0.31) - 0.26) * exp(noteTime * -1.0); + return clamp(v * amp * 2.0, -1.0, 1.0); +} +``` + +### Variant 2: IIR Biquad Filter +Time-domain IIR filter based on the Audio EQ Cookbook, supporting 7 types including low-pass/high-pass/band-pass. +```glsl +float waveSaw(float freq, int samp) { + return fract(freq * float(samp) / iSampleRate) * 2.0 - 1.0; +} + +vec2 widerSaw(float freq, int samp) { + int offset = int(freq) * 64; + return vec2(waveSaw(freq, samp - offset), waveSaw(freq, samp + offset)); +} + +void biquadLPF(float freq, float Q, float sr, + out float b0, out float b1, out float b2, + out float a0, out float a1, out float a2) { + float omega = TAU * freq / sr; + float sn = sin(omega), cs = cos(omega); + float alpha = sn / (2.0 * Q); + b0 = (1.0 - cs) * 0.5; b1 = 1.0 - cs; b2 = (1.0 - cs) * 0.5; + a0 = 1.0 + alpha; a1 = -2.0 * cs; a2 = 1.0 - alpha; +} +``` + +### Variant 3: Vocal / Formant Synthesis +Vocal tract model simulating human voice by synthesizing vowels through formant frequencies and bandwidths. +```glsl +float tract(float x, float formantFreq, float bandwidth) { + return sin(TAU * formantFreq * x) * exp(-bandwidth * 3.14159 * x); +} + +float vowel_aah(float t, float pitch) { + float x = mod(t, 1.0 / pitch); + float aud = tract(x, 710.0, 70.0) * 0.5 // F1 + + tract(x, 1000.0, 90.0) * 0.6 // F2 + + tract(x, 2450.0, 140.0) * 0.4; // F3 + return aud; +} + +float fricative(float t, float formantFreq) { + return (hash11(floor(formantFreq * t) * 20.0) - 0.5) * 3.0; +} +``` + +### Variant 4: Algorithmic Composition +Hash pseudo-random melody + scale quantization, multi-layer rhythmic subdivision producing fractal music structures. +```glsl +vec2 noteRing(float n) { + float r = 0.5 + 0.5 * fract(sin(mod(floor(n), 32.123) * 32.123) * 41.123); + n = mod(n, 8.0); + float note = n<1.?0. : n<2.?5. : n<3.?-2. : n<4.?4. : n<5.?7. : n<6.?4. : n<7.?2. : 0.; + return vec2(note, r); +} + +vec2 generativeNote(float beat) { + float b2 = floor(beat * 0.25); + return noteRing(b2 * 0.0625) + noteRing(b2 * 0.25) + noteRing(b2); +} +``` + +### Variant 5: Circle of Fifths Chord Progressions +Automatically generates harmony based on the circle of fifths, advancing +7 semitones every 4 beats, alternating major/minor chords. +```glsl +vec2 mainSound(int samp, float time) { + float id = floor(time / SPB / 4.0); + float offset = id * 7.0; + float minor = mod(id, 4.0) >= 3.0 ? 1.0 : 0.0; + float t = mod(time, SPB * 4.0); + float root = 57.0 + mod(offset, 12.0); + vec2 result = chord(t, root, minor); + result += vec2(0.5, 0.2) * chord(t - SPB * 0.5, root, minor); + result += vec2(0.05, 0.1) * chord(t - SPB, root, minor); + return result; +} +``` + +## Performance & Composition + +**Performance Tips:** +- Harmonic count (`NUM_HARMONICS` / `NSPC`) is the biggest bottleneck; start with 4-8, stop when sufficient +- IIR filters require looping through sample history per output sample; prefer frequency-domain methods +- Each delay tap requires recomputing the full signal chain; 4 taps = 5x computation +- `fract(x)` is faster than `mod(x, 1.0)`; hoist constants out of loops +- Use Common Pass to share constants; avoid redundant computation between Sound and Image + +**Composition Tips:** +- **Audio visualization**: Sound output is read via `iChannel0` in the Image shader for spectrum display +- **Raymarching sync**: Common Pass defines shared timeline; Sound/Image reference it synchronously +- **Particle systems**: Use kick triggers to drive particle emission; share BPM/SPB for beat position calculation +- **Post-processing linkage**: Sidechain compression coefficients drive bloom/chromatic aberration/dithering via Common Pass +- **Text overlay**: `message()` in Image shader renders parameter display or interaction instructions + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/sound-synthesis.md) diff --git a/skills/shader-dev/techniques/terrain-rendering.md b/skills/shader-dev/techniques/terrain-rendering.md new file mode 100644 index 0000000..6163f8f --- /dev/null +++ b/skills/shader-dev/techniques/terrain-rendering.md @@ -0,0 +1,408 @@ +# Heightfield Ray Marching Terrain Rendering + +## Use Cases + +- Procedural generation of natural landscapes (mountains, canyons, dunes, etc.) in ShaderToy / Fragment Shaders +- Complete 3D terrain flythrough scenes in a single pixel shader, without geometry +- Cinematic aerial perspective, soft shadows, and layered material effects + +## Core Principles + +Rendering pipeline: height field definition → ray marching intersection → normals & materials → lighting → atmospheric effects + +- **FBM**: `f(p) = Σ (aⁿ × noise(2ⁿ × R × p))`, a=0.5, R=rotation matrix, 2ⁿ=frequency doubling +- **Derivative erosion**: `f(p) = Σ (aⁿ × noise(p) / (1 + dot(d,d)))`, d=accumulated gradient, suppresses detail on steep slopes +- **Adaptive step size**: `step = factor × (ray.y - terrain_height)` + +## Implementation Steps + +1. **Noise & hash** — sin-free hash + Value Noise with analytic derivatives (`noised` returns value + partial derivatives) +2. **FBM terrain** — derivative erosion FBM, `mat2(0.8,-0.6,0.6,0.8)` per-layer rotation to eliminate banding; LOD tiers (L=3/M=9/H=16 octaves) +3. **Ray marching** — upper bound clipping + adaptive step `STEP_FACTOR * h` + distance-adaptive precision `abs(h) < 0.0015*t` +4. **Normals** — finite differences, epsilon increases with distance to avoid distant aliasing, using high-precision `terrainH` +5. **Soft shadows** — march toward sun, track `min(k*h/t)` to estimate penumbra +6. **Materials** — blend rock/grass/snow/sand by height + slope + noise +7. **Lighting** — Lambert diffuse + hemisphere ambient + backlight + Fresnel rim light + Blinn-Phong specular +8. **Atmospheric fog** — wavelength-dependent attenuation `exp(-t*k*vec3(1,1.5,4))` + sun scatter fog color +9. **Sky** — zenith-to-horizon gradient + sun disk/halo +10. **Camera** — Look-At matrix + path-following flight, height tracks terrain + +## Complete Code Template + +```glsl +// ===================================================== +// Heightfield Terrain Rendering - Complete Template +// ===================================================== +#define TERRAIN_OCTAVES 9 // FBM octave count (3~16) +#define TERRAIN_SCALE 0.003 // Terrain spatial frequency +#define TERRAIN_HEIGHT 120.0 // Terrain elevation scale +#define MAX_STEPS 300 // Ray march step count (80~400) +#define MAX_DIST 5000.0 // Maximum render distance +#define STEP_FACTOR 0.4 // March conservative factor (0.3~0.8) +#define SHADOW_STEPS 80 // Shadow step count (32~128) +#define SHADOW_K 16.0 // Penumbra softness (8~64) +#define FOG_DENSITY 0.00025 // Fog density +#define SNOW_HEIGHT 80.0 // Snow line height +#define CAM_ALTITUDE 20.0 // Camera height above ground +#define SUN_DIR normalize(vec3(0.8, 0.4, -0.6)) +#define SUN_COL vec3(8.0, 5.0, 3.0) +#define SKY_COL vec3(0.5, 0.7, 1.0) + +// ---- Hash & Noise ---- +float hash(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 19.19); + return fract((p3.x + p3.y) * p3.z); +} + +vec3 noised(in vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + vec2 du = 6.0 * f * (1.0 - f); + float a = hash(i + vec2(0.0, 0.0)); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + float v = a + (b - a) * u.x + (c - a) * u.y + (a - b - c + d) * u.x * u.y; + vec2 g = du * (vec2(b - a, c - a) + (a - b - c + d) * u.yx); + return vec3(v, g); +} + +float noise(in vec2 p) { return noised(p).x; } + +// ---- FBM Terrain (derivative erosion) + LOD ---- +const mat2 m2 = mat2(0.8, -0.6, 0.6, 0.8); + +float terrainFBM(in vec2 p, int octaves) { + p *= TERRAIN_SCALE; + float a = 0.0, b = 1.0; + vec2 d = vec2(0.0); + for (int i = 0; i < 16; i++) { + if (i >= octaves) break; + vec3 n = noised(p); + d += n.yz; + a += b * n.x / (1.0 + dot(d, d)); + b *= 0.5; + p = m2 * p * 2.0; + } + return a * TERRAIN_HEIGHT; +} + +float terrainL(vec2 p) { return terrainFBM(p, 3); } +float terrainM(vec2 p) { return terrainFBM(p, TERRAIN_OCTAVES); } +float terrainH(vec2 p) { return terrainFBM(p, 16); } + +// ---- Ray Marching ---- +float raymarch(in vec3 ro, in vec3 rd) { + float t = 0.0; + if (ro.y > TERRAIN_HEIGHT && rd.y >= 0.0) return -1.0; + if (ro.y > TERRAIN_HEIGHT) t = (ro.y - TERRAIN_HEIGHT) / (-rd.y); + for (int i = 0; i < MAX_STEPS; i++) { + vec3 pos = ro + t * rd; + float h = pos.y - terrainM(pos.xz); + if (abs(h) < 0.0015 * t) break; + if (t > MAX_DIST) return -1.0; + t += STEP_FACTOR * h; + } + return t; +} + +// ---- Normals ---- +vec3 calcNormal(in vec3 pos, float t) { + float eps = 0.02 + 0.00005 * t * t; + float hC = terrainH(pos.xz); + float hR = terrainH(pos.xz + vec2(eps, 0.0)); + float hU = terrainH(pos.xz + vec2(0.0, eps)); + return normalize(vec3(hC - hR, eps, hC - hU)); +} + +// ---- Soft Shadows ---- +float calcShadow(in vec3 pos, in vec3 sunDir) { + float res = 1.0, t = 1.0; + for (int i = 0; i < SHADOW_STEPS; i++) { + vec3 p = pos + t * sunDir; + float h = p.y - terrainM(p.xz); + if (h < 0.001) return 0.0; + res = min(res, SHADOW_K * h / t); + t += clamp(h, 2.0, 100.0); + } + return clamp(res, 0.0, 1.0); +} + +// ---- Materials ---- +vec3 getMaterial(in vec3 pos, in vec3 nor) { + float slope = nor.y, h = pos.y; + float nz = noise(pos.xz * 0.04) * noise(pos.xz * 0.005); + vec3 rock = vec3(0.10, 0.09, 0.08); + vec3 grass = mix(vec3(0.10, 0.08, 0.04), vec3(0.05, 0.09, 0.02), nz); + vec3 snow = vec3(0.62, 0.65, 0.70); + vec3 sand = vec3(0.50, 0.45, 0.35); + vec3 col = rock; + col = mix(col, grass, smoothstep(0.5, 0.8, slope)); + float snowMask = smoothstep(SNOW_HEIGHT - 20.0 * nz, SNOW_HEIGHT + 10.0, h) + * smoothstep(0.3, 0.7, slope); + col = mix(col, snow, snowMask); + float beachMask = smoothstep(2.5, 0.0, h) * smoothstep(0.5, 0.9, slope); + col = mix(col, sand, beachMask); + return col; +} + +// ---- Lighting ---- +vec3 calcLighting(in vec3 pos, in vec3 nor, in vec3 rd, float shadow) { + float dif = clamp(dot(nor, SUN_DIR), 0.0, 1.0); + float amb = 0.5 + 0.5 * nor.y; + vec3 backDir = normalize(vec3(-SUN_DIR.x, 0.0, -SUN_DIR.z)); + float bac = clamp(0.2 + 0.8 * dot(nor, backDir), 0.0, 1.0); + float fre = pow(clamp(1.0 + dot(rd, nor), 0.0, 1.0), 2.0); + vec3 hal = normalize(SUN_DIR - rd); + float spe = pow(clamp(dot(nor, hal), 0.0, 1.0), 16.0) + * (0.04 + 0.96 * pow(1.0 + dot(hal, rd), 5.0)); + vec3 lin = vec3(0.0); + lin += dif * shadow * SUN_COL * 0.1; + lin += amb * SKY_COL * 0.2; + lin += bac * vec3(0.15, 0.05, 0.04); + lin += fre * SKY_COL * 0.3; + lin += spe * shadow * SUN_COL * 0.05; + return lin; +} + +// ---- Atmosphere ---- +vec3 applyFog(in vec3 col, float t, in vec3 rd) { + vec3 ext = exp(-t * FOG_DENSITY * vec3(1.0, 1.5, 4.0)); + float sundot = clamp(dot(rd, SUN_DIR), 0.0, 1.0); + vec3 fogCol = mix(vec3(0.55, 0.55, 0.58), vec3(1.0, 0.7, 0.3), 0.3 * pow(sundot, 8.0)); + return col * ext + fogCol * (1.0 - ext); +} + +// ---- Sky ---- +vec3 getSky(in vec3 rd) { + vec3 col = vec3(0.3, 0.5, 0.85) - rd.y * vec3(0.2, 0.15, 0.0); + float horizon = pow(1.0 - max(rd.y, 0.0), 4.0); + col = mix(col, vec3(0.8, 0.75, 0.7), 0.5 * horizon); + float sundot = clamp(dot(rd, SUN_DIR), 0.0, 1.0); + col += vec3(1.0, 0.7, 0.3) * 0.3 * pow(sundot, 8.0); + col += vec3(1.0, 0.9, 0.7) * 0.5 * pow(sundot, 64.0); + col += vec3(1.0, 1.0, 0.9) * min(pow(sundot, 1150.0), 0.3); + return col; +} + +// ---- Camera ---- +vec3 cameraPath(float t) { + return vec3(100.0 * sin(0.2 * t), 0.0, -100.0 * t); +} + +mat3 setCamera(in vec3 ro, in vec3 ta) { + vec3 cw = normalize(ta - ro); + vec3 cu = normalize(cross(cw, vec3(0.0, 1.0, 0.0))); + vec3 cv = cross(cu, cw); + return mat3(cu, cv, cw); +} + +// ======== Main Function ======== +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; + float time = iTime * 0.5; + vec3 ro = cameraPath(time); + ro.y = terrainL(ro.xz) + CAM_ALTITUDE; + vec3 ta = cameraPath(time + 2.0); + ta.y = terrainL(ta.xz) + CAM_ALTITUDE * 0.5; + mat3 cam = setCamera(ro, ta); + vec3 rd = cam * normalize(vec3(uv, 1.5)); + + float t = raymarch(ro, rd); + vec3 col; + if (t > 0.0) { + vec3 pos = ro + t * rd; + vec3 nor = calcNormal(pos, t); + vec3 mate = getMaterial(pos, nor); + float sha = calcShadow(pos + nor * 0.5, SUN_DIR); + vec3 lin = calcLighting(pos, nor, rd, sha); + col = mate * lin; + col = applyFog(col, t, rd); + } else { + col = getSky(rd); + } + col = 1.0 - exp(-col * 2.0); + col = pow(col, vec3(1.0 / 2.2)); + fragColor = vec4(col, 1.0); +} +``` + +### Binary Refinement (optional, called after raymarch) + +```glsl +float bisect(in vec3 ro, in vec3 rd, float tNear, float tFar) { + for (int i = 0; i < 5; i++) { + float tMid = 0.5 * (tNear + tFar); + vec3 pos = ro + tMid * rd; + float h = pos.y - terrainM(pos.xz); + if (h > 0.0) tNear = tMid; else tFar = tMid; + } + return 0.5 * (tNear + tFar); +} +``` + +## Common Variants + +### Relaxation Marching + +Automatically increases step size at far distances, covering greater range in 90 steps. + +```glsl +float raymarchRelax(in vec3 ro, in vec3 rd) { + float t = 0.0; + float d = (ro + rd * t).y - terrainM((ro + rd * t).xz); + for (int i = 0; i < 90; i++) { + if (abs(d) < t * 0.0001 || t > 400.0) break; + float rl = max(t * 0.02, 1.0); + t += d * rl; + vec3 pos = ro + t * rd; + d = (pos.y - terrainM(pos.xz)) * 0.7; + } + return t; +} +``` + +### Sign-Alternating FBM + +Amplitude flips sign each layer, producing rugged alternating ridge/valley patterns. + +```glsl +float terrainSignFlip(in vec2 p) { + p *= TERRAIN_SCALE; + float a = 0.0, w = 1.0; + for (int i = 0; i < TERRAIN_OCTAVES; i++) { + a += w * noise(p); + w = -w * 0.4; + p = m2 * p * 2.0; + } + return a * TERRAIN_HEIGHT; +} +``` + +### Canyon Style (Texture-Driven + 3D Displacement) + +Texture sampling + 3D FBM displacement, supporting cliffs/caves and other non-heightfield formations. + +```glsl +float noise3D(in vec3 x) { + vec3 p = floor(x); vec3 f = fract(x); + f = f * f * (3.0 - 2.0 * f); + vec2 uv = (p.xy + vec2(37.0, 17.0) * p.z) + f.xy; + vec2 rg = textureLod(iChannel0, (uv + 0.5) / 256.0, 0.0).yx; + return mix(rg.x, rg.y, f.z); +} + +const mat3 m3 = mat3(0.00, 0.80, 0.60, -0.80, 0.36,-0.48, -0.60,-0.48, 0.64); + +float displacement(vec3 p) { + float f = 0.5 * noise3D(p); p = m3 * p * 2.02; + f += 0.25 * noise3D(p); p = m3 * p * 2.03; + f += 0.125 * noise3D(p); p = m3 * p * 2.01; + f += 0.0625 * noise3D(p); + return f; +} + +float mapCanyon(vec3 p) { + float h = terrainM(p.xz); + float dis = displacement(0.25 * p * vec3(1.0, 4.0, 1.0)) * 3.0; + return (dis + p.y - h) * 0.25; +} +``` + +### Directional Erosion Noise + +Slope direction drives Gabor noise projection, producing realistic dendritic drainage patterns. + +```glsl +#define EROSION_BRANCH 1.5 + +vec3 erosionNoise(vec2 p, vec2 dir) { + vec2 ip = floor(p); vec2 fp = fract(p) - 0.5; + float va = 0.0, wt = 0.0; vec2 dva = vec2(0.0); + for (int i = -2; i <= 1; i++) + for (int j = -2; j <= 1; j++) { + vec2 o = vec2(float(i), float(j)); + vec2 h = hash2(ip - o) * 0.5; + vec2 pp = fp + o + h; + float d = dot(pp, pp); + float w = exp(-d * 2.0); + float mag = dot(pp, dir); + va += cos(mag * 6.283) * w; + dva += -sin(mag * 6.283) * dir * w; + wt += w; + } + return vec3(va, dva) / wt; +} + +float terrainErosion(vec2 p, vec2 baseSlope) { + float e = 0.0, a = 0.5; + vec2 dir = normalize(baseSlope + vec2(0.001)); + for (int i = 0; i < 5; i++) { + vec3 n = erosionNoise(p * 4.0, dir); + e += a * n.x; + dir = normalize(dir + n.zy * vec2(1.0, -1.0) * EROSION_BRANCH); + a *= 0.5; p *= 2.0; + } + return e; +} +``` + +### Volumetric Clouds + God Rays + +Front-to-back alpha compositing of cloud slabs, accumulating god ray factor. + +```glsl +#define CLOUD_BASE 200.0 +#define CLOUD_TOP 300.0 + +vec4 raymarchClouds(vec3 ro, vec3 rd) { + float tmin = (CLOUD_BASE - ro.y) / rd.y; + float tmax = (CLOUD_TOP - ro.y) / rd.y; + if (tmin > tmax) { float tmp = tmin; tmin = tmp; tmax = tmp; } + if (tmin < 0.0) tmin = 0.0; + float t = tmin; + vec4 sum = vec4(0.0); float rays = 0.0; + for (int i = 0; i < 64; i++) { + if (sum.a > 0.99 || t > tmax) break; + vec3 pos = ro + t * rd; + float hFrac = (pos.y - CLOUD_BASE) / (CLOUD_TOP - CLOUD_BASE); + float shape = 1.0 - 2.0 * abs(hFrac - 0.5); + float den = shape - 1.6 * (1.0 - noise(pos.xz * 0.01)); + if (den > 0.0) { + float shadowDen = shape - 1.6 * (1.0 - noise((pos.xz + SUN_DIR.xz * 30.0) * 0.01)); + float shadow = clamp(1.0 - shadowDen * 2.0, 0.0, 1.0); + vec3 cloudCol = mix(vec3(0.4, 0.4, 0.45), vec3(1.0, 0.95, 0.8), shadow); + float alpha = clamp(den * 0.4, 0.0, 1.0); + rays += 0.02 * shadow * (1.0 - sum.a); + cloudCol *= alpha; + sum += vec4(cloudCol, alpha) * (1.0 - sum.a); + } + t += max(0.5, 0.05 * t); + } + sum.rgb += pow(rays, 3.0) * 0.4 * vec3(1.0, 0.8, 0.7); + return sum; +} +``` + +## Performance & Composition + +**Performance:** +- LOD tiers: low octaves for marching (3-9), high octaves for normals (16), lowest for camera (3) +- Upper bound clipping: intersect ray with terrain max height plane before marching +- Adaptive precision: hit threshold `abs(h) < k * t`, tolerates larger error at distance +- Texture instead of noise: `textureLod` sampling of pre-baked noise, 2-3x speed +- Early exit: `t > MAX_DIST`, `alpha > 0.99`, shadow `h < 0` +- Dithered start: `t += hash(fragCoord) * step_size` to eliminate banding artifacts + +**Composition:** +- Terrain + water: water at a fixed y-plane, multi-frequency noise perturbing normals, Fresnel controlling reflection/refraction +- Terrain + volumetric clouds: render terrain first, then march cloud slab, front-to-back alpha compositing +- Terrain + volumetric fog: additionally sample 3D FBM density field along ray, decay with distance +- Terrain + SDF objects: `floor(p.xz/gridSize)` grid placement, `hash(cell)` randomization +- Terrain + TAA: inter-frame reprojection blending, ~10% new frame + 90% history frame + +## Further Reading + +For full step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/terrain-rendering.md) diff --git a/skills/shader-dev/techniques/texture-mapping-advanced.md b/skills/shader-dev/techniques/texture-mapping-advanced.md new file mode 100644 index 0000000..54f8ab7 --- /dev/null +++ b/skills/shader-dev/techniques/texture-mapping-advanced.md @@ -0,0 +1,121 @@ +# Advanced Texture Mapping Techniques + +## Use Cases +- Texturing 3D surfaces without UV seams (triplanar/biplanar mapping) +- Eliminating visible tiling repetition on large surfaces +- Proper texture filtering in ray-marched scenes (mip-level selection) +- Combining procedural and sampled textures + +## Techniques + +### 1. Biplanar Mapping (Optimized Triplanar) + +Uses only 2 texture fetches instead of 3, selecting the two most relevant projection axes: + +```glsl +vec4 biplanar(sampler2D sam, vec3 p, vec3 n, float k) { + vec3 dpdx = dFdx(p); + vec3 dpdy = dFdy(p); + n = abs(n); + + // Determine major, minor, median axes + ivec3 ma = (n.x > n.y && n.x > n.z) ? ivec3(0,1,2) : + (n.y > n.z) ? ivec3(1,2,0) : ivec3(2,0,1); + ivec3 mi = (n.x < n.y && n.x < n.z) ? ivec3(0,1,2) : + (n.y < n.z) ? ivec3(1,2,0) : ivec3(2,0,1); + ivec3 me = ivec3(3) - mi - ma; + + // Two texture fetches (major and median projections) + vec4 x = textureGrad(sam, vec2(p[ma.y], p[ma.z]), + vec2(dpdx[ma.y], dpdx[ma.z]), + vec2(dpdy[ma.y], dpdy[ma.z])); + vec4 y = textureGrad(sam, vec2(p[me.y], p[me.z]), + vec2(dpdx[me.y], dpdx[me.z]), + vec2(dpdy[me.y], dpdy[me.z])); + + // Blend weights with local support + vec2 w = vec2(n[ma.x], n[me.x]); + w = clamp((w - 0.5773) / (1.0 - 0.5773), 0.0, 1.0); // 0.5773 = 1/sqrt(3) + w = pow(w, vec2(k / 8.0)); + + return (x * w.x + y * w.y) / (w.x + w.y); +} +// Usage: vec4 col = biplanar(tex, worldPos * scale, worldNormal, 8.0); +``` + +**Why biplanar over triplanar**: Saves one texture fetch (bandwidth-bound advantage), with k=8 visually equivalent to triplanar. The `dFdx/dFdy` gradient propagation prevents mipmap seams at axis-switching boundaries. + +### 2. Texture Repetition Avoidance + +Three approaches to eliminate visible tiling patterns: + +#### Method A: Per-Tile Random Offset (4 fetches) +```glsl +vec4 textureNoTile(sampler2D sam, vec2 uv) { + vec2 iuv = floor(uv); + vec2 fuv = fract(uv); + + // Generate 4 random offsets for the 4 surrounding tiles + vec4 ofa = hash42(iuv + vec2(0, 0)); + vec4 ofb = hash42(iuv + vec2(1, 0)); + vec4 ofc = hash42(iuv + vec2(0, 1)); + vec4 ofd = hash42(iuv + vec2(1, 1)); + + // Transform UVs per tile + vec2 uva = uv + ofa.xy; + vec2 uvb = uv + ofb.xy; + vec2 uvc = uv + ofc.xy; + vec2 uvd = uv + ofd.xy; + + // Blend near borders with smooth weights + vec2 b = smoothstep(0.25, 0.75, fuv); + return mix(mix(texture(sam, uva), texture(sam, uvb), b.x), + mix(texture(sam, uvc), texture(sam, uvd), b.x), b.y); +} +``` + +#### Method B: Virtual Pattern (2 fetches, cheapest) +```glsl +vec4 textureNoTileCheap(sampler2D sam, vec2 uv) { + float k = texture(iChannel1, 0.005 * uv).x; // low-freq variation index + float index = k * 8.0; + float i = floor(index); + float f = fract(index); + + // Two offset lookups based on index + vec2 offa = sin(vec2(3.0, 7.0) * (i + 0.0)); + vec2 offb = sin(vec2(3.0, 7.0) * (i + 1.0)); + + return mix(texture(sam, uv + offa), texture(sam, uv + offb), smoothstep(0.2, 0.8, f)); +} +``` + +### 3. Ray Differential Texture Filtering + +For ray-marched scenes, compute proper mip levels using ray differentials: +```glsl +// After finding hit point pos with normal nor: +// 1. Compute neighbor pixel ray directions +vec3 rdx = normalize(rd + dFdx(rd)); // x-neighbor ray +vec3 rdy = normalize(rd + dFdy(rd)); // y-neighbor ray + +// 2. Intersect neighbors with tangent plane at hit point +float dt_dx = -dot(pos - ro, nor) / dot(rdx, nor); +float dt_dy = -dot(pos - ro, nor) / dot(rdy, nor); +vec3 posDx = ro + rdx * dt_dx; +vec3 posDy = ro + rdy * dt_dy; + +// 3. World-space position derivatives = pixel footprint +vec3 dposdx = posDx - pos; +vec3 dposdy = posDy - pos; + +// 4. Transform to texture derivatives and use textureGrad +// For simple planar mapping (e.g. ground plane): +vec2 duvdx = dposdx.xz * textureScale; +vec2 duvdy = dposdy.xz * textureScale; +vec4 color = textureGrad(tex, pos.xz * textureScale, duvdx, duvdy); +``` + +This provides correct mip-level selection for procedural and sampled textures on ray-marched surfaces, eliminating shimmer and aliasing at distance. + +→ For deeper details, see [reference/texture-mapping-advanced.md](../reference/texture-mapping-advanced.md) diff --git a/skills/shader-dev/techniques/texture-sampling.md b/skills/shader-dev/techniques/texture-sampling.md new file mode 100644 index 0000000..23437ea --- /dev/null +++ b/skills/shader-dev/techniques/texture-sampling.md @@ -0,0 +1,382 @@ +**IMPORTANT - GLSL Type Strictness**: +- GLSL is a strongly-typed language and does not support the `string` type (you cannot define `string var`) +- `vec2`/`vec3`/`vec4` are vector types and cannot be directly assigned a float (e.g., `vec2 a = 1.0` must be `vec2 a = vec2(1.0)`) +- Array indices must be integer constants or uniform variables; runtime-computed floats cannot be used +- Avoid uninitialized variables — GLSL default values are undefined + +# Texture Sampling + +## Use Cases + +- **Post-processing effects**: Blur, bloom, dispersion, chromatic aberration +- **Procedural noise**: FBM layering from noise textures to generate terrain, clouds, fire +- **PBR/IBL**: Cubemap environment lighting, BRDF LUT lookup +- **Simulation/feedback systems**: Reaction-diffusion, fluid simulation multi-buffer feedback +- **Data storage**: Textures used as structured data (game state, keyboard input) +- **Temporal accumulation**: TAA, motion blur, previous frame reading + +## Core Principles + +| Function | Coordinate Type | Filtering | Typical Use | +|----------|----------------|-----------|-------------| +| `texture(sampler, uv)` | Float UV `[0,1]` | Hardware bilinear | General texture reading | +| `textureLod(sampler, uv, lod)` | Float UV + LOD | Specified mip level | Control blur level / avoid auto mip | +| `texelFetch(sampler, ivec2, lod)` | Integer pixel coordinates | No filtering | Exact pixel data reading | + +Key mathematics: +1. **Hardware bilinear interpolation**: `texture()` automatically linearly blends between 4 adjacent texels +2. **Quintic Hermite smoothing**: `u = f^3(6f^2 - 15f + 10)`, C2 continuous (eliminates hardware linear interpolation seams) +3. **LOD control**: `textureLod` third parameter selects mipmap level, `lod=0` is original resolution, each +1 halves resolution +4. **Coordinate wrapping**: `fract(uv)` implements torus boundary, equivalent to `GL_REPEAT` + +## Implementation Steps + +### Step 1: Basic Sampling and UV Normalization + +```glsl +vec2 uv = fragCoord / iResolution.xy; +vec4 col = texture(iChannel0, uv); +``` + +### Step 2: textureLod for Mipmap Control + +```glsl +// In ray marching: force LOD 0 to avoid artifacts +vec3 groundCol = textureLod(iChannel2, groundUv * 0.05, 0.0).rgb; + +// Depth of field blur: LOD varies with distance +float focus = mix(maxBlur - coverage, minBlur, smoothstep(.1, .2, coverage)); +vec3 col = textureLod(iChannel0, uv + normal, focus).rgb; + +// Bloom: sample high mip levels +#define BLOOM_LOD_A 4.0 // adjustable: bloom first mip level +#define BLOOM_LOD_B 5.0 +#define BLOOM_LOD_C 6.0 +vec3 bloom = vec3(0.0); +bloom += textureLod(iChannel0, uv + off * exp2(BLOOM_LOD_A), BLOOM_LOD_A).rgb; +bloom += textureLod(iChannel0, uv + off * exp2(BLOOM_LOD_B), BLOOM_LOD_B).rgb; +bloom += textureLod(iChannel0, uv + off * exp2(BLOOM_LOD_C), BLOOM_LOD_C).rgb; +bloom /= 3.0; +``` + +### Step 3: texelFetch for Exact Pixel Reading + +```glsl +// Data storage addresses +const ivec2 txBallPosVel = ivec2(0, 0); +const ivec2 txPaddlePos = ivec2(1, 0); +const ivec2 txPoints = ivec2(2, 0); +const ivec2 txState = ivec2(3, 0); + +vec4 loadValue(in ivec2 addr) { + return texelFetch(iChannel0, addr, 0); +} + +void storeValue(in ivec2 addr, in vec4 val, inout vec4 fragColor, in ivec2 fragPos) { + fragColor = (fragPos == addr) ? val : fragColor; +} + +// Keyboard input +float key = texelFetch(iChannel1, ivec2(KEY_SPACE, 0), 0).x; +``` + +### Step 4: Manual Bilinear + Quintic Hermite Smoothing + +```glsl +float noise(vec2 x) { + vec2 p = floor(x); + vec2 f = fract(x); + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); // C2 continuous + + #define TEX_RES 1024.0 // adjustable: noise texture resolution + float a = texture(iChannel0, (p + vec2(0.0, 0.0)) / TEX_RES).x; + float b = texture(iChannel0, (p + vec2(1.0, 0.0)) / TEX_RES).x; + float c = texture(iChannel0, (p + vec2(0.0, 1.0)) / TEX_RES).x; + float d = texture(iChannel0, (p + vec2(1.0, 1.0)) / TEX_RES).x; + + return a + (b - a) * u.x + (c - a) * u.y + (a - b - c + d) * u.x * u.y; +} +``` + +### Step 5: FBM Texture Noise + +```glsl +#define FBM_OCTAVES 5 // adjustable: number of layers +#define FBM_PERSISTENCE 0.5 // adjustable: amplitude decay rate + +float fbm(vec2 x) { + float v = 0.0; + float a = 0.5; + float totalWeight = 0.0; + for (int i = 0; i < FBM_OCTAVES; i++) { + v += a * noise(x); + totalWeight += a; + x *= 2.0; + a *= FBM_PERSISTENCE; + } + return v / totalWeight; +} +``` + +### Step 6: Separable Gaussian Blur + +```glsl +#define BLUR_RADIUS 4 // adjustable: blur radius + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec2 d = vec2(1.0 / iResolution.x, 0.0); // horizontal pass; for vertical pass change to vec2(0, 1/iResolution.y) + float w[9] = float[9](0.05, 0.09, 0.12, 0.15, 0.16, 0.15, 0.12, 0.09, 0.05); + + vec4 col = vec4(0.0); + for (int i = -4; i <= 4; i++) { + col += w[i + 4] * texture(iChannel0, fract(uv + float(i) * d)); + } + col /= 0.98; + fragColor = col; +} +``` + +### Step 7: Dispersion Sampling + +```glsl +#define DISP_SAMPLES 64 // adjustable: sample count + +vec3 sampleWeights(float i) { + return vec3(i * i, 46.6666 * pow((1.0 - i) * i, 3.0), (1.0 - i) * (1.0 - i)); +} + +vec3 sampleDisp(sampler2D tex, vec2 uv, vec2 disp) { + vec3 col = vec3(0.0); + vec3 totalWeight = vec3(0.0); + for (int i = 0; i < DISP_SAMPLES; i++) { + float t = float(i) / float(DISP_SAMPLES); + vec3 w = sampleWeights(t); + col += w * texture(tex, fract(uv + disp * t)).rgb; + totalWeight += w; + } + return col / totalWeight; +} +``` + +### Step 8: IBL Environment Sampling + +```glsl +#define MAX_LOD 7.0 // adjustable: cubemap max mip level +#define DIFFUSE_LOD 6.5 // adjustable: diffuse sampling LOD + +vec3 getSpecularLightColor(vec3 N, float roughness) { + vec3 raw = textureLod(iChannel0, N, roughness * MAX_LOD).rgb; + return pow(raw, vec3(4.5)) * 6.5; // HDR approximation +} + +vec3 getDiffuseLightColor(vec3 N) { + return textureLod(iChannel0, N, DIFFUSE_LOD).rgb; +} + +// BRDF LUT lookup +vec2 brdf = texture(iChannel3, vec2(NdotV, roughness)).rg; +vec3 specular = envColor * (F * brdf.x + brdf.y); +``` + +## Complete Code Template + +iChannel0 bound to a noise texture (e.g., "Gray Noise Medium"), with mipmap enabled. + +```glsl +// === Texture Sampling Comprehensive Demo === +// iChannel0: noise texture (requires mipmap enabled) + +#define TEX_RES 256.0 +#define FBM_OCTAVES 6 +#define FBM_PERSISTENCE 0.5 +#define CLOUD_LAYERS 4 +#define CLOUD_SPEED 0.02 +#define DOF_MAX_BLUR 5.0 +#define DOF_FOCUS_DIST 0.5 +#define BLOOM_STRENGTH 0.3 +#define BLOOM_LOD 4.0 + +float noise(vec2 x) { + vec2 p = floor(x); + vec2 f = fract(x); + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + + float a = textureLod(iChannel0, (p + vec2(0.0, 0.0)) / TEX_RES, 0.0).x; + float b = textureLod(iChannel0, (p + vec2(1.0, 0.0)) / TEX_RES, 0.0).x; + float c = textureLod(iChannel0, (p + vec2(0.0, 1.0)) / TEX_RES, 0.0).x; + float d = textureLod(iChannel0, (p + vec2(1.0, 1.0)) / TEX_RES, 0.0).x; + + return a + (b - a) * u.x + (c - a) * u.y + (a - b - c + d) * u.x * u.y; +} + +float fbm(vec2 x) { + float v = 0.0; + float a = 0.5; + float w = 0.0; + for (int i = 0; i < FBM_OCTAVES; i++) { + v += a * noise(x); + w += a; + x *= 2.0; + a *= FBM_PERSISTENCE; + } + return v / w; +} + +float cloudLayer(vec2 uv, float height, float time) { + vec2 offset = vec2(time * CLOUD_SPEED * (1.0 + height), 0.0); + float n = fbm((uv + offset) * (2.0 + height * 3.0)); + return smoothstep(0.4, 0.7, n); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + float aspect = iResolution.x / iResolution.y; + + // 1. Procedural sky + vec3 sky = mix(vec3(0.1, 0.15, 0.4), vec3(0.5, 0.7, 1.0), uv.y); + + // 2. FBM cloud layers + vec3 col = sky; + for (int i = 0; i < CLOUD_LAYERS; i++) { + float h = float(i) / float(CLOUD_LAYERS); + float density = cloudLayer(vec2(uv.x * aspect, uv.y), h, iTime); + vec3 cloudCol = mix(vec3(0.8, 0.85, 0.9), vec3(1.0), h); + col = mix(col, cloudCol, density * (0.3 + 0.7 * h)); + } + + // 3. textureLod depth of field blur + float dist = abs(uv.y - DOF_FOCUS_DIST); + float lod = dist * DOF_MAX_BLUR; + vec3 blurred = textureLod(iChannel0, uv, lod).rgb; + col = mix(col, blurred * 0.5 + col * 0.5, 0.3); + + // 4. Bloom + vec3 bloom = textureLod(iChannel0, uv, BLOOM_LOD).rgb; + bloom += textureLod(iChannel0, uv, BLOOM_LOD + 1.0).rgb; + bloom += textureLod(iChannel0, uv, BLOOM_LOD + 2.0).rgb; + bloom /= 3.0; + col += bloom * BLOOM_STRENGTH; + + // 5. Post-processing + col = (col * (6.2 * col + 0.5)) / (col * (6.2 * col + 1.7) + 0.06); + col *= 0.5 + 0.5 * pow(16.0 * uv.x * uv.y * (1.0 - uv.x) * (1.0 - uv.y), 0.2); + + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Variant 1: Anisotropic Flow-Field Blur + +```glsl +#define BLUR_ITERATIONS 32 // adjustable: number of samples along flow field +#define BLUR_STEP 0.008 // adjustable: UV offset per step + +vec3 flowBlur(vec2 uv) { + vec3 col = vec3(0.0); + float acc = 0.0; + for (int i = 0; i < BLUR_ITERATIONS; i++) { + float h = float(i) / float(BLUR_ITERATIONS); + float w = 4.0 * h * (1.0 - h); + col += w * texture(iChannel0, uv).rgb; + acc += w; + vec2 dir = texture(iChannel1, uv).xy * 2.0 - 1.0; + uv += BLUR_STEP * dir; + } + return col / acc; +} +``` + +### Variant 2: Buffer-as-Data Storage + +```glsl +const ivec2 txPosition = ivec2(0, 0); +const ivec2 txVelocity = ivec2(1, 0); +const ivec2 txState = ivec2(2, 0); + +vec4 load(ivec2 addr) { return texelFetch(iChannel0, addr, 0); } + +void store(ivec2 addr, vec4 val, inout vec4 fragColor, ivec2 fragPos) { + fragColor = (fragPos == addr) ? val : fragColor; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + ivec2 p = ivec2(fragCoord); + fragColor = texelFetch(iChannel0, p, 0); + vec4 pos = load(txPosition); + vec4 vel = load(txVelocity); + // ... update logic ... + store(txPosition, pos + vel * 0.016, fragColor, p); + store(txVelocity, vel, fragColor, p); +} +``` + +### Variant 3: Dispersion Effect + +```glsl +#define DISP_SAMPLES 64 // adjustable: sample count +#define DISP_STRENGTH 0.05 // adjustable: dispersion strength + +vec3 dispersion(vec2 uv, vec2 displacement) { + vec3 col = vec3(0.0); + vec3 w_total = vec3(0.0); + for (int i = 0; i < DISP_SAMPLES; i++) { + float t = float(i) / float(DISP_SAMPLES); + vec3 w = vec3(t * t, 46.666 * pow((1.0 - t) * t, 3.0), (1.0 - t) * (1.0 - t)); + col += w * texture(iChannel0, fract(uv + displacement * t * DISP_STRENGTH)).rgb; + w_total += w; + } + return col / w_total; +} +``` + +### Variant 4: Triplanar Texture Mapping + +```glsl +#define TRIPLANAR_SHARPNESS 2.0 // adjustable: blend sharpness + +vec3 triplanarSample(sampler2D tex, vec3 pos, vec3 normal, float scale) { + vec3 w = pow(abs(normal), vec3(TRIPLANAR_SHARPNESS)); + w /= (w.x + w.y + w.z); + vec3 xSample = texture(tex, pos.yz * scale).rgb; + vec3 ySample = texture(tex, pos.xz * scale).rgb; + vec3 zSample = texture(tex, pos.xy * scale).rgb; + return xSample * w.x + ySample * w.y + zSample * w.z; +} +``` + +### Variant 5: Temporal Reprojection (TAA) + +```glsl +#define TAA_BLEND 0.9 // adjustable: history frame blend ratio + +vec3 temporalBlend(vec2 currUv, vec2 prevUv, vec3 currColor) { + vec3 history = textureLod(iChannel0, prevUv, 0.0).rgb; + vec3 minCol = currColor - 0.1; + vec3 maxCol = currColor + 0.1; + history = clamp(history, minCol, maxCol); + return mix(currColor, history, TAA_BLEND); +} +``` + +## Performance & Composition + +**Performance Tips**: +- Heavy sampling (e.g., 64 dispersion samples) is a bandwidth bottleneck — reduce sample count + use smart weight compensation; use `textureLod` with high LOD to reduce cache misses +- 2D Gaussian blur uses separable two-pass (O(N^2) -> O(2N)), leveraging hardware bilinear for (N+1)/2 samples to achieve N-tap +- Must use `textureLod(..., 0.0)` inside ray marching — the GPU cannot correctly estimate screen-space derivatives +- Manual Hermite interpolation is ~4x slower than hardware — only use for the first two FBM octaves, fall back to `texture()` for higher frequencies +- Each multi-buffer feedback adds one frame of latency — merge operations into the same pass; use `texelFetch` to avoid filtering overhead + +**Composition Tips**: +- **+ SDF Ray Marching**: Noise textures for displacement maps/materials; use `textureLod(..., 0.0)` inside ray marching +- **+ Procedural Noise**: Hermite + FBM driving domain warping to generate terrain/clouds/fire; texture noise is faster than pure mathematical noise +- **+ Post-Processing Pipeline**: Multi-LOD bloom → separable DOF → dispersion → tone mapping, chaining a complete post-processing pipeline +- **+ PBR/IBL**: `textureLod` samples cubemap by roughness + BRDF LUT lookup = split-sum IBL +- **+ Simulation/Feedback**: Multi-buffer reaction-diffusion/fluid; Buffer A state, B/C separable blur diffusion, Image visualization; `fract()` torus boundary + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/texture-sampling.md) diff --git a/skills/shader-dev/techniques/volumetric-rendering.md b/skills/shader-dev/techniques/volumetric-rendering.md new file mode 100644 index 0000000..31e0371 --- /dev/null +++ b/skills/shader-dev/techniques/volumetric-rendering.md @@ -0,0 +1,375 @@ +# Volumetric Rendering Skill + +## Use Cases +- Rendering participating media: clouds, fog, smoke, fire, explosions, atmospheric scattering +- Visual effects of light passing through and scattering/absorbing within semi-transparent volumes +- Suitable for ShaderToy real-time fragment shaders, also portable to game engines + +## Core Principles + +Advance along each view ray at fixed or adaptive step sizes (Ray Marching), querying medium density at each sample point, accumulating color and opacity. + +### Key Formulas + +**Beer-Lambert transmittance**: `T = exp(-σe × d)`, where `σe = σs + σa` + +**Front-to-back alpha compositing (premultiplied form)**: +```glsl +col.rgb *= col.a; +sum += col * (1.0 - sum.a); +``` + +**Henyey-Greenstein phase function**: `HG(cosθ, g) = (1 - g²) / (1 + g² - 2g·cosθ)^(3/2)` +- `g > 0` forward scattering, `g < 0` back scattering, `g = 0` isotropic + +**Frostbite improved integration**: `Sint = (S - S × exp(-σe × dt)) / σe` + +## Implementation Steps + +### Step 1: Camera and Ray Construction +```glsl +vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; +vec3 ro = vec3(0.0, 1.0, -5.0); // Camera position +vec3 ta = vec3(0.0, 0.0, 0.0); // Look-at target +vec3 ww = normalize(ta - ro); +vec3 uu = normalize(cross(ww, vec3(0.0, 1.0, 0.0))); +vec3 vv = cross(uu, ww); +float fl = 1.5; // Focal length +vec3 rd = normalize(uv.x * uu + uv.y * vv + fl * ww); +``` + +### Step 2: Volume Bounds Intersection +```glsl +// Method A: Horizontal plane bounds (cloud layers) +float tmin = (yBottom - ro.y) / rd.y; +float tmax = (yTop - ro.y) / rd.y; +if (tmin > tmax) { float tmp = tmin; tmin = tmax; tmax = tmp; } + +// Method B: Sphere bounds (explosions, atmosphere) +vec2 intersectSphere(vec3 ro, vec3 rd, float r) { + float b = dot(ro, rd); + float c = dot(ro, ro) - r * r; + float d = b * b - c; + if (d < 0.0) return vec2(1e5, -1e5); + d = sqrt(d); + return vec2(-b - d, -b + d); +} +``` + +### Step 3: Density Field Definition +```glsl +// 3D Value Noise (texture-based) +float noise(vec3 x) { + vec3 p = floor(x); + vec3 f = fract(x); + f = f * f * (3.0 - 2.0 * f); + vec2 uv = (p.xy + vec2(37.0, 239.0) * p.z) + f.xy; + vec2 rg = textureLod(iChannel0, (uv + 0.5) / 256.0, 0.0).yx; + return mix(rg.x, rg.y, f.z); +} + +// fBM +float fbm(vec3 p) { + float f = 0.0; + f += 0.50000 * noise(p); p *= 2.02; + f += 0.25000 * noise(p); p *= 2.03; + f += 0.12500 * noise(p); p *= 2.01; + f += 0.06250 * noise(p); p *= 2.02; + f += 0.03125 * noise(p); + return f; +} + +// Cloud density +float cloudDensity(vec3 p) { + vec3 q = p - vec3(0.0, 0.1, 1.0) * iTime; + float f = fbm(q); + return clamp(1.5 - p.y - 2.0 + 1.75 * f, 0.0, 1.0); +} +``` + +### Step 4: Ray Marching Main Loop +```glsl +#define NUM_STEPS 64 +#define STEP_SIZE 0.05 + +vec4 raymarch(vec3 ro, vec3 rd, float tmin, float tmax, vec3 bgCol) { + vec4 sum = vec4(0.0); + // Dither start position to eliminate banding artifacts + float t = tmin + STEP_SIZE * fract(sin(dot(fragCoord, vec2(12.9898, 78.233))) * 43758.5453); + + for (int i = 0; i < NUM_STEPS; i++) { + if (t > tmax || sum.a > 0.99) break; + vec3 pos = ro + t * rd; + float den = cloudDensity(pos); + if (den > 0.01) { + vec4 col = vec4(1.0, 0.95, 0.8, den); + col.a *= 0.4; + col.rgb *= col.a; + sum += col * (1.0 - sum.a); + } + t += STEP_SIZE; + } + return clamp(sum, 0.0, 1.0); +} +``` + +### Step 5: Lighting Calculation +```glsl +// Method A: Directional derivative lighting (1 extra sample) +vec3 sundir = normalize(vec3(1.0, 0.0, -1.0)); +float dif = clamp((den - cloudDensity(pos + 0.3 * sundir)) / 0.6, 0.0, 1.0); +vec3 lin = vec3(1.0, 0.6, 0.3) * dif + vec3(0.91, 0.98, 1.05); + +// Method B: Volumetric shadow (secondary ray march) +float volumetricShadow(vec3 from, vec3 lightDir) { + float shadow = 1.0, dt = 0.5, d = dt * 0.5; + for (int s = 0; s < 6; s++) { + shadow *= exp(-cloudDensity(from + lightDir * d) * dt); + dt *= 1.3; d += dt; + } + return shadow; +} + +// Method C: HG phase function mixed scattering +float HenyeyGreenstein(float cosTheta, float g) { + float gg = g * g; + return (1.0 - gg) / pow(1.0 + gg - 2.0 * g * cosTheta, 1.5); +} +float scattering = mix( + HenyeyGreenstein(dot(rd, -sundir), 0.8), + HenyeyGreenstein(dot(rd, -sundir), -0.2), + 0.5 +); +``` + +### Step 6: Color Mapping +```glsl +// Method A: Density-interpolated coloring (clouds) +vec3 cloudColor = mix(vec3(1.0, 0.95, 0.8), vec3(0.25, 0.3, 0.35), den); + +// Method B: Radial gradient coloring (explosions, fire) +vec3 computeColor(float density, float radius) { + vec3 result = mix(vec3(1.0, 0.9, 0.8), vec3(0.4, 0.15, 0.1), density); + result *= mix(7.0 * vec3(0.8, 1.0, 1.0), 1.5 * vec3(0.48, 0.53, 0.5), min(radius / 0.9, 1.15)); + return result; +} + +// Method C: Height-based ambient light gradient +vec3 ambientLight = mix( + vec3(39., 67., 87.) * (1.5 / 255.), + vec3(149., 167., 200.) * (1.5 / 255.), + normalizedHeight +); +``` + +### Step 7: Final Compositing and Post-Processing +```glsl +// Sky background +vec3 bgCol = vec3(0.6, 0.71, 0.75) - rd.y * 0.2 * vec3(1.0, 0.5, 1.0); +float sun = clamp(dot(sundir, rd), 0.0, 1.0); +bgCol += 0.2 * vec3(1.0, 0.6, 0.1) * pow(sun, 8.0); + +// Compositing +vec4 vol = raymarch(ro, rd, tmin, tmax, bgCol); +vec3 col = bgCol * (1.0 - vol.a) + vol.rgb; +col += vec3(0.2, 0.08, 0.04) * pow(sun, 3.0); // Sun glare +col = smoothstep(0.15, 1.1, col); // Tone mapping +``` + +## Complete Code Template + +Runnable volumetric cloud renderer for ShaderToy (iChannel0 = Gray Noise Small 256x256): + +```glsl +// Volumetric Cloud Renderer — ShaderToy Template + +#define NUM_STEPS 80 +#define SUN_DIR normalize(vec3(-0.7, 0.0, -0.7)) +#define CLOUD_BOTTOM -1.0 +#define CLOUD_TOP 2.0 +#define WIND_SPEED 0.1 +#define DENSITY_SCALE 1.75 +#define DENSITY_THRESHOLD 0.01 + +float noise(vec3 x) { + vec3 p = floor(x); + vec3 f = fract(x); + f = f * f * (3.0 - 2.0 * f); + vec2 uv = (p.xy + vec2(37.0, 239.0) * p.z) + f.xy; + vec2 rg = textureLod(iChannel0, (uv + 0.5) / 256.0, 0.0).yx; + return mix(rg.x, rg.y, f.z) * 2.0 - 1.0; +} + +float map(vec3 p, int lod) { + vec3 q = p - vec3(0.0, WIND_SPEED, 1.0) * iTime; + float f; + f = 0.50000 * noise(q); q *= 2.02; + if (lod >= 2) + f += 0.25000 * noise(q); q *= 2.03; + if (lod >= 3) + f += 0.12500 * noise(q); q *= 2.01; + if (lod >= 4) + f += 0.06250 * noise(q); q *= 2.02; + if (lod >= 5) + f += 0.03125 * noise(q); + return clamp(1.5 - p.y - 2.0 + DENSITY_SCALE * f, 0.0, 1.0); +} + +vec3 lightSample(vec3 pos, float den, int lod) { + float dif = clamp((den - map(pos + 0.3 * SUN_DIR, lod)) / 0.6, 0.0, 1.0); + vec3 lin = vec3(1.0, 0.6, 0.3) * dif + vec3(0.91, 0.98, 1.05); + vec3 col = mix(vec3(1.0, 0.95, 0.8), vec3(0.25, 0.3, 0.35), den); + return col * lin; +} + +vec4 raymarch(vec3 ro, vec3 rd, vec3 bgcol, ivec2 px) { + float tmin = (CLOUD_BOTTOM - ro.y) / rd.y; + float tmax = (CLOUD_TOP - ro.y) / rd.y; + if (tmin > tmax) { float tmp = tmin; tmin = tmax; tmax = tmp; } + if (tmax < 0.0) return vec4(0.0); + tmin = max(tmin, 0.0); + tmax = min(tmax, 60.0); + + float t = tmin + 0.1 * fract(sin(float(px.x * 73 + px.y * 311)) * 43758.5453); + vec4 sum = vec4(0.0); + + for (int i = 0; i < NUM_STEPS; i++) { + float dt = max(0.05, 0.02 * t); + int lod = 5 - int(log2(1.0 + t * 0.5)); + vec3 pos = ro + t * rd; + float den = map(pos, lod); + + if (den > DENSITY_THRESHOLD) { + vec3 litCol = lightSample(pos, den, lod); + litCol = mix(litCol, bgcol, 1.0 - exp(-0.003 * t * t)); + vec4 col = vec4(litCol, den); + col.a *= 0.4; + col.rgb *= col.a; + sum += col * (1.0 - sum.a); + } + + t += dt; + if (t > tmax || sum.a > 0.99) break; + } + return clamp(sum, 0.0, 1.0); +} + +mat3 setCamera(vec3 ro, vec3 ta, float cr) { + vec3 cw = normalize(ta - ro); + vec3 cp = vec3(sin(cr), cos(cr), 0.0); + vec3 cu = normalize(cross(cw, cp)); + vec3 cv = normalize(cross(cu, cw)); + return mat3(cu, cv, cw); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; + vec2 m = iMouse.xy / iResolution.xy; + + vec3 ro = 4.0 * normalize(vec3(sin(3.0 * m.x), 0.8 * m.y, cos(3.0 * m.x))); + ro.y += 0.5; + vec3 ta = vec3(0.0, -1.0, 0.0); + mat3 ca = setCamera(ro, ta, 0.07 * cos(0.25 * iTime)); + vec3 rd = ca * normalize(vec3(p, 1.5)); + + float sun = clamp(dot(SUN_DIR, rd), 0.0, 1.0); + vec3 bgcol = vec3(0.6, 0.71, 0.75) - rd.y * 0.2 * vec3(1.0, 0.5, 1.0) + 0.075; + bgcol += 0.2 * vec3(1.0, 0.6, 0.1) * pow(sun, 8.0); + + vec4 res = raymarch(ro, rd, bgcol, ivec2(fragCoord - 0.5)); + vec3 col = bgcol * (1.0 - res.a) + res.rgb; + col += vec3(0.2, 0.08, 0.04) * pow(sun, 3.0); + col = smoothstep(0.15, 1.1, col); + + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Variant 1: Self-Emissive Volume (Fire/Explosions) +```glsl +vec3 emissionColor(float density, float radius) { + vec3 result = mix(vec3(1.0, 0.9, 0.8), vec3(0.4, 0.15, 0.1), density); + vec3 colCenter = 7.0 * vec3(0.8, 1.0, 1.0); + vec3 colEdge = 1.5 * vec3(0.48, 0.53, 0.5); + result *= mix(colCenter, colEdge, min(radius / 0.9, 1.15)); + return result; +} +// Bloom effect +sum.rgb += lightColor / exp(lDist * lDist * lDist * 0.08) / 30.0; +``` + +### Variant 2: Physical Scattering Atmosphere (Rayleigh + Mie) +```glsl +float density(vec3 p, float scaleHeight) { + return exp(-max(length(p) - R_INNER, 0.0) / scaleHeight); +} +float opticDepth(vec3 from, vec3 to, float scaleHeight) { + vec3 s = (to - from) / float(NUM_STEPS_LIGHT); + vec3 v = from + s * 0.5; + float sum = 0.0; + for (int i = 0; i < NUM_STEPS_LIGHT; i++) { sum += density(v, scaleHeight); v += s; } + return sum * length(s); +} +float phaseRayleigh(float cc) { return (3.0 / 16.0 / PI) * (1.0 + cc); } +vec3 scatter = sumRay * kRay * phaseRayleigh(cc) + sumMie * kMie * phaseMie(-0.78, c, cc); +``` + +### Variant 3: Frostbite Energy-Conserving Integration +```glsl +vec3 S = evaluateLight(p) * sigmaS * phaseFunction() * volumetricShadow(p, lightPos); +vec3 Sint = (S - S * exp(-sigmaE * dt)) / sigmaE; +scatteredLight += transmittance * Sint; +transmittance *= exp(-sigmaE * dt); +``` + +### Variant 4: Production-Grade Clouds (Horizon Zero Dawn Style) +```glsl +float m = cloudMapBase(pos, norY); +m *= cloudGradient(norY); +m -= cloudMapDetail(pos) * dstrength * 0.225; +m = smoothstep(0.0, 0.1, m + (COVERAGE - 1.0)); +float scattering = mix(HenyeyGreenstein(sundotrd, 0.8), HenyeyGreenstein(sundotrd, -0.2), 0.5); +// Temporal reprojection +vec2 spos = reprojectPos(ro + rd * dist, iResolution.xy, iChannel1); +col = mix(texture(iChannel1, spos, 0.0), col, 0.05); +``` + +### Variant 5: Gradient Normal Surface Lighting (Fur Ball / Volume Surface) +```glsl +vec3 furNormal(vec3 pos, float density) { + float eps = 0.01; + vec3 n; + n.x = sampleDensity(pos + vec3(eps, 0, 0)) - density; + n.y = sampleDensity(pos + vec3(0, eps, 0)) - density; + n.z = sampleDensity(pos + vec3(0, 0, eps)) - density; + return normalize(n); +} +vec3 N = -furNormal(pos, density); +float diff = max(0.0, dot(N, L) * 0.5 + 0.5); // Half-Lambert +float spec = pow(max(0.0, dot(N, H)), 50.0); // Blinn-Phong +``` + +## Performance & Composition + +### Performance Tips +- **Early exit**: break out of loop when `sum.a > 0.99` +- **LOD noise**: `int lod = 5 - int(log2(1.0 + t * 0.5));` reduce fBM octaves at distance +- **Adaptive step size**: `float dt = max(0.05, 0.02 * t);` fine near, coarse far +- **Dithering**: add pixel-dependent random offset to start position, eliminates banding artifacts +- **Bounds clipping**: only march within the ray-volume intersection interval +- **Density threshold skip**: only compute lighting when `den > 0.01` +- **Minimal shadow steps**: 6-16 steps with increasing step size +- **Temporal reprojection**: blend history frames (e.g., 5% new frame + 95% history frame) + +### Composition Tips +- **SDF terrain + volumetric clouds**: mutual depth occlusion (Himalayas style) +- **Volumetric fog + scene lighting**: `color = color * transmittance + scatteredLight` +- **Multi-layer volumes**: different density functions at different heights, march independently then composite +- **Post-process light shafts (God Rays)**: radial blur or screen-space ray marching +- **Procedural sky + volumetric clouds**: distance fogging for natural transitions + +## Further Reading + +For full step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/volumetric-rendering.md) diff --git a/skills/shader-dev/techniques/voronoi-cellular-noise.md b/skills/shader-dev/techniques/voronoi-cellular-noise.md new file mode 100644 index 0000000..7860358 --- /dev/null +++ b/skills/shader-dev/techniques/voronoi-cellular-noise.md @@ -0,0 +1,458 @@ +- **IMPORTANT:** All declared `uniform` variables must be used in the shader code, otherwise the compiler will optimize them away. After optimization, `gl.getUniformLocation()` returns `null`, and setting that uniform triggers a WebGL `INVALID_OPERATION` error, which may cause rendering failure. Ensure uniforms like `iTime` are actually used in `main()` (e.g., `float t = iTime * 1.0;`) + +# Voronoi & Cellular Noise + +## Use Cases +- Natural textures: cells, cracked soil, stone, skin pores +- Structured patterns: crystals, honeycombs, shattered glass, mosaics +- Effects: fire/nebula (fBm stacking), crack generation +- Procedural materials: cloud noise, terrain height maps, stylized partitioning + +## Core Principles + +Voronoi noise = **spatial partitioning**: scatter feature points, assign each pixel to the "cell" of its nearest feature point. + +Algorithm flow: +1. `floor` divides into an integer grid; each cell contains a randomly offset feature point +2. Search the 3x3 (2D) or 3x3x3 (3D) neighborhood for all feature points +3. Record the nearest distance F1 (optionally second-nearest F2) +4. Map F1, F2, or F2-F1 to color/height/shape + +Distance metrics: +- Euclidean: `dot(r,r)` (squared, fast) -> final `sqrt` +- Manhattan: `abs(r.x)+abs(r.y)` +- Chebyshev: `max(abs(r.x), abs(r.y))` + +Exact border distance (two-pass algorithm): `dot(0.5*(mr+r), normalize(r-mr))` +Rounded borders (harmonic mean): `1/(1/(d2-d1) + 1/(d3-d1))` + +## Implementation Steps + +### Step 1: Hash Functions + +```glsl +// sin-dot hash (suitable for most cases) +vec2 hash2(vec2 p) { + p = vec2(dot(p, vec2(127.1, 311.7)), + dot(p, vec2(269.5, 183.3))); + return fract(sin(p) * 43758.5453); +} + +// 3D version +vec3 hash3(vec3 p) { + float n = sin(dot(p, vec3(7.0, 157.0, 113.0))); + return fract(vec3(2097152.0, 262144.0, 32768.0) * n); +} + +// High-quality integer hash (ES 3.0+, more uniform) +vec3 hash3_uint(vec3 p) { + uvec3 q = uvec3(ivec3(p)) * uvec3(1597334673U, 3812015801U, 2798796415U); + q = (q.x ^ q.y ^ q.z) * uvec3(1597334673U, 3812015801U, 2798796415U); + return vec3(q) / float(0xffffffffU); +} +``` + +### Step 2: Basic F1 Voronoi + +```glsl +// Returns (F1 distance, cell ID) +vec2 voronoi(vec2 x) { + vec2 n = floor(x); + vec2 f = fract(x); + vec3 m = vec3(8.0); + + for (int j = -1; j <= 1; j++) + for (int i = -1; i <= 1; i++) { + vec2 g = vec2(float(i), float(j)); + vec2 o = hash2(n + g); + vec2 r = g - f + o; + float d = dot(r, r); + if (d < m.x) { + m = vec3(d, o); + } + } + return vec2(sqrt(m.x), m.y + m.z); +} +``` + +### Step 3: F1 + F2 (Edge Detection) + +```glsl +// Returns vec2(F1, F2), edge value = F2 - F1 +vec2 voronoi_f1f2(vec2 x) { + vec2 p = floor(x); + vec2 f = fract(x); + vec2 res = vec2(8.0); + + for (int j = -1; j <= 1; j++) + for (int i = -1; i <= 1; i++) { + vec2 b = vec2(i, j); + vec2 r = b - f + hash2(p + b); + float d = dot(r, r); + if (d < res.x) { + res.y = res.x; + res.x = d; + } else if (d < res.y) { + res.y = d; + } + } + return sqrt(res); +} +``` + +### Step 4: Exact Border Distance (Two-Pass Algorithm) + +```glsl +// Returns vec3(border distance, nearest point offset) +vec3 voronoi_border(vec2 x) { + vec2 ip = floor(x); + vec2 fp = fract(x); + + // First pass: find nearest feature point + vec2 mg, mr; + float md = 8.0; + for (int j = -1; j <= 1; j++) + for (int i = -1; i <= 1; i++) { + vec2 g = vec2(float(i), float(j)); + vec2 o = hash2(ip + g); + vec2 r = g + o - fp; + float d = dot(r, r); + if (d < md) { md = d; mr = r; mg = g; } + } + + // Second pass: exact border distance (5x5 range) + md = 8.0; + for (int j = -2; j <= 2; j++) + for (int i = -2; i <= 2; i++) { + vec2 g = mg + vec2(float(i), float(j)); + vec2 o = hash2(ip + g); + vec2 r = g + o - fp; + if (dot(mr - r, mr - r) > 0.00001) + md = min(md, dot(0.5 * (mr + r), normalize(r - mr))); + } + return vec3(md, mr); +} +``` + +### Step 5: Feature Point Animation + +```glsl +// Replace static hash inside the neighborhood search loop: +vec2 o = hash2(n + g); +o = 0.5 + 0.5 * sin(iTime + 6.2831 * o); // different phase per point +vec2 r = g - f + o; +``` + +### Step 6: Coloring & Visualization + +```glsl +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + // Must use iTime, otherwise the compiler will optimize away the uniform + float time = iTime * 1.0; + vec2 p = fragCoord.xy / iResolution.xy; + vec2 uv = p * SCALE; + + vec2 c = voronoi(uv); + float dist = c.x; + float id = c.y; + + // Cell coloring (ID-driven palette) + vec3 col = 0.5 + 0.5 * cos(id * 6.2831 + vec3(0.0, 1.0, 2.0)); + // Distance falloff + col *= clamp(1.0 - 0.4 * dist * dist, 0.0, 1.0); + // Border lines + col -= (1.0 - smoothstep(0.08, 0.09, dist)); + + fragColor = vec4(col, 1.0); +} +``` + +## Complete Code Template + +```glsl +// === Voronoi Cellular Noise — Complete ShaderToy Template === +// Supports F1/F2/F2-F1 modes, multiple distance metrics, animation, exact borders + +#define SCALE 8.0 // Cell density +#define ANIMATE 1 // 0=static, 1=animated +#define MODE 0 // 0=F1 fill, 1=F2-F1 edges, 2=exact borders +#define DIST_METRIC 0 // 0=Euclidean, 1=Manhattan, 2=Chebyshev + +vec2 hash2(vec2 p) { + p = vec2(dot(p, vec2(127.1, 311.7)), + dot(p, vec2(269.5, 183.3))); + return fract(sin(p) * 43758.5453); +} + +float distFunc(vec2 r) { + #if DIST_METRIC == 0 + return dot(r, r); + #elif DIST_METRIC == 1 + return abs(r.x) + abs(r.y); + #elif DIST_METRIC == 2 + return max(abs(r.x), abs(r.y)); + #endif +} + +vec2 getPoint(vec2 cellId) { + vec2 o = hash2(cellId); + #if ANIMATE + o = 0.5 + 0.5 * sin(iTime + 6.2831 * o); + #endif + return o; +} + +vec4 voronoi(vec2 x) { + vec2 n = floor(x); + vec2 f = fract(x); + float d1 = 8.0, d2 = 8.0; + vec2 nearestCell = vec2(0.0); + + for (int j = -1; j <= 1; j++) + for (int i = -1; i <= 1; i++) { + vec2 g = vec2(float(i), float(j)); + vec2 o = getPoint(n + g); + vec2 r = g - f + o; + float d = distFunc(r); + if (d < d1) { + d2 = d1; d1 = d; + nearestCell = n + g; + } else if (d < d2) { + d2 = d; + } + } + + #if DIST_METRIC == 0 + d1 = sqrt(d1); d2 = sqrt(d2); + #endif + return vec4(d1, d2, nearestCell); +} + +vec3 voronoiBorder(vec2 x) { + vec2 ip = floor(x); + vec2 fp = fract(x); + + vec2 mg, mr; + float md = 8.0; + for (int j = -1; j <= 1; j++) + for (int i = -1; i <= 1; i++) { + vec2 g = vec2(float(i), float(j)); + vec2 o = getPoint(ip + g); + vec2 r = g + o - fp; + float d = dot(r, r); + if (d < md) { md = d; mr = r; mg = g; } + } + + md = 8.0; + for (int j = -2; j <= 2; j++) + for (int i = -2; i <= 2; i++) { + vec2 g = mg + vec2(float(i), float(j)); + vec2 o = getPoint(ip + g); + vec2 r = g + o - fp; + if (dot(mr - r, mr - r) > 0.00001) + md = min(md, dot(0.5 * (mr + r), normalize(r - mr))); + } + return vec3(md, mr); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + // Must use iTime, otherwise the compiler will optimize away the uniform (especially important when ANIMATE=1) + float time = iTime * 1.0; + vec2 p = fragCoord.xy / iResolution.xy; + p.x *= iResolution.x / iResolution.y; + vec2 uv = p * SCALE; + vec3 col = vec3(0.0); + + #if MODE == 0 + vec4 v = voronoi(uv); + float id = dot(v.zw, vec2(127.1, 311.7)); + col = 0.5 + 0.5 * cos(id * 6.2831 + vec3(0.0, 1.0, 2.0)); + col *= clamp(1.0 - 0.4 * v.x * v.x, 0.0, 1.0); + col -= (1.0 - smoothstep(0.08, 0.09, v.x)); + #elif MODE == 1 + vec4 v = voronoi(uv); + float edge = v.y - v.x; + col = vec3(1.0 - smoothstep(0.0, 0.15, edge)); + col *= vec3(0.2, 0.6, 1.0); + #elif MODE == 2 + vec3 c = voronoiBorder(uv); + col = c.x * (0.5 + 0.5 * sin(64.0 * c.x)) * vec3(1.0); + col = mix(vec3(1.0, 0.6, 0.0), col, smoothstep(0.04, 0.07, c.x)); + float dd = length(c.yz); + col = mix(vec3(1.0, 0.6, 0.1), col, smoothstep(0.0, 0.12, dd)); + #endif + + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Variant 1: 3D Voronoi + fBm Fire + +```glsl +#define NUM_OCTAVES 5 + +vec3 hash3(vec3 p) { + float n = sin(dot(p, vec3(7.0, 157.0, 113.0))); + return fract(vec3(2097152.0, 262144.0, 32768.0) * n); +} + +float voronoi3D(vec3 p) { + vec3 g = floor(p); p = fract(p); + float d = 1.0; + for (int j = -1; j <= 1; j++) + for (int i = -1; i <= 1; i++) + for (int k = -1; k <= 1; k++) { + vec3 b = vec3(i, j, k); + vec3 r = b - p + hash3(g + b); + d = min(d, dot(r, r)); + } + return d; +} + +float fbmVoronoi(vec3 p) { + vec3 t = vec3(0.0, 0.0, p.z + iTime * 1.5); + float tot = 0.0, sum = 0.0, amp = 1.0; + for (int i = 0; i < NUM_OCTAVES; i++) { + tot += voronoi3D(p + t) * amp; + p *= 2.0; t *= 1.5; + sum += amp; amp *= 0.5; + } + return tot / sum; +} + +// Blackbody radiation palette +vec3 firePalette(float i) { + float T = 1400.0 + 1300.0 * i; + vec3 L = vec3(7.4, 5.6, 4.4); + L = pow(L, vec3(5.0)) * (exp(1.43876719683e5 / (T * L)) - 1.0); + return 1.0 - exp(-5e8 / L); +} +``` + +### Variant 2: Rounded Borders (3rd-Order Voronoi) + +```glsl +float voronoiRounded(vec2 p) { + vec2 g = floor(p); p -= g; + vec3 d = vec3(1.0); // F1, F2, F3 + + for (int y = -1; y <= 1; y++) + for (int x = -1; x <= 1; x++) { + vec2 o = vec2(x, y); + o += hash2(g + o) - p; + float r = dot(o, o); + d.z = max(d.x, max(d.y, min(d.z, r))); + d.y = max(d.x, min(d.y, r)); + d.x = min(d.x, r); + } + d = sqrt(d); + return min(2.0 / (1.0 / max(d.y - d.x, 0.001) + + 1.0 / max(d.z - d.x, 0.001)), 1.0); +} +``` + +### Variant 3: Voronoise (Unified Noise-Voronoi Framework) + +```glsl +#define JITTER 1.0 // 0=regular grid, 1=fully random +#define SMOOTH 0.0 // 0=sharp Voronoi, 1=smooth noise + +vec3 hash3(vec2 p) { + vec3 q = vec3(dot(p, vec2(127.1, 311.7)), + dot(p, vec2(269.5, 183.3)), + dot(p, vec2(419.2, 371.9))); + return fract(sin(q) * 43758.5453); +} + +float voronoise(vec2 p, float u, float v) { + float k = 1.0 + 63.0 * pow(1.0 - v, 6.0); + vec2 i = floor(p); vec2 f = fract(p); + vec2 a = vec2(0.0); + for (int y = -2; y <= 2; y++) + for (int x = -2; x <= 2; x++) { + vec2 g = vec2(x, y); + vec3 o = hash3(i + g) * vec3(u, u, 1.0); + vec2 d = g - f + o.xy; + float w = pow(1.0 - smoothstep(0.0, 1.414, length(d)), k); + a += vec2(o.z * w, w); + } + return a.x / a.y; +} +``` + +### Variant 4: Crack Texture (Multi-Layer Recursive Voronoi) + +```glsl +#define CRACK_DEPTH 3.0 +#define CRACK_WIDTH 0.0 +#define CRACK_SLOPE 50.0 + +float ofs = 0.5; +#define disp(p) (-ofs + (1.0 + 2.0 * ofs) * hash2(p)) + +// Main loop +vec4 O = vec4(0.0); +vec2 U = uv; +for (float i = 0.0; i < CRACK_DEPTH; i++) { + vec2 D = fbm22(U) * 0.67; + vec3 H = voronoiBorder(U + D); + float d = H.x; + d = min(1.0, CRACK_SLOPE * pow(max(0.0, d - CRACK_WIDTH), 1.0)); + O += vec4(1.0 - d) / exp2(i); + U *= 1.5 * rot(0.37); +} +``` + +### Variant 5: Tileable 3D Worley (Cloud Noise) + +```glsl +#define TILE_FREQ 4.0 + +float worleyTileable(vec3 uv, float freq) { + vec3 id = floor(uv); vec3 p = fract(uv); + float minDist = 1e4; + for (float x = -1.0; x <= 1.0; x++) + for (float y = -1.0; y <= 1.0; y++) + for (float z = -1.0; z <= 1.0; z++) { + vec3 offset = vec3(x, y, z); + vec3 h = hash3_uint(mod(id + offset, vec3(freq))) * 0.5 + 0.5; + h += offset; + vec3 d = p - h; + minDist = min(minDist, dot(d, d)); + } + return 1.0 - minDist; +} + +float worleyFbm(vec3 p, float freq) { + return worleyTileable(p * freq, freq) * 0.625 + + worleyTileable(p * freq * 2.0, freq * 2.0) * 0.25 + + worleyTileable(p * freq * 4.0, freq * 4.0) * 0.125; +} + +float remap(float x, float a, float b, float c, float d) { + return (((x - a) / (b - a)) * (d - c)) + c; +} +// cloud = remap(perlinNoise, worleyFbm - 1.0, 1.0, 0.0, 1.0); +``` + +## Performance & Composition + +**Performance:** +- Use `dot(r,r)` instead of `length` during comparison; only `sqrt` for final output +- 3D loops can be manually unrolled along the z-axis to reduce nesting +- Search range: basic F1 uses 3x3; exact borders/Voronoise/extended jitter uses 5x5 +- Hash choice: `sin(dot(...))` is fastest; integer hash is more uniform but requires ES 3.0+ +- fBm layers: 3 is sufficient, 5 is the upper limit + +**Combinations:** +- **+fBm distortion**: `uv + 0.5*fbm22(uv*2.0)` -> organic cell shapes +- **+Bump Mapping**: finite-difference normal computation -> pseudo-3D bumps +- **+Palette**: `0.5+0.5*cos(6.2831*(t+vec3(0,0.33,0.67)))` -> rich colors +- **+Raymarching**: Voronoi distance as part of the SDF -> cellular surfaces +- **+Multi-scale stacking**: Voronoi at different frequencies stacked -> primary structure + fine detail + +## Further Reading + +For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/voronoi-cellular-noise.md) diff --git a/skills/shader-dev/techniques/voxel-rendering.md b/skills/shader-dev/techniques/voxel-rendering.md new file mode 100644 index 0000000..67fb570 --- /dev/null +++ b/skills/shader-dev/techniques/voxel-rendering.md @@ -0,0 +1,985 @@ +## WebGL2 Adaptation Requirements + +The code templates in this document use ShaderToy GLSL style. When generating standalone HTML pages, you must adapt for WebGL2: + +- Use `canvas.getContext("webgl2")` **(required! WebGL1 does not support in/out keywords)** +- Shader first line: `#version 300 es`, add `precision highp float;` to fragment shader +- **IMPORTANT: #version must be the very first line of the shader! No characters before it (including blank lines/comments/Unicode BOM)** +- Vertex shader: `attribute` → `in`, `varying` → `out` +- Fragment shader: `varying` → `in`, `gl_FragColor` → custom `out vec4 fragColor`, `texture2D()` → `texture()` +- ShaderToy's `void mainImage(out vec4 fragColor, in vec2 fragCoord)` needs to be adapted to the standard `void main()` entry point + +### WebGL2 Full Adaptation Example + +```glsl +// === Vertex Shader === +const vertexShaderSource = `#version 300 es +in vec2 a_position; +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + +// === Fragment Shader === +const fragmentShaderSource = `#version 300 es +precision highp float; + +uniform float iTime; +uniform vec2 iResolution; + +// IMPORTANT: Important: WebGL2 must declare the output variable! +out vec4 fragColor; + +// ... other functions ... + +void main() { + // IMPORTANT: Use gl_FragCoord.xy instead of fragCoord + vec2 fragCoord = gl_FragCoord.xy; + + vec3 col = vec3(0.0); + + // ... rendering logic ... + + // IMPORTANT: Write to fragColor, not gl_FragColor! + fragColor = vec4(col, 1.0); +}`; +``` + +**IMPORTANT: Common GLSL compile errors:** +- `in/out storage qualifier supported in GLSL ES 3.00 only` → Check that you are using `getContext("webgl2")` and `#version 300 es` +- `#version directive must occur on the first line` → Check that the shader string starts with #version, with no characters before it +- **IMPORTANT: GLSL reserved words**: `cast`, `class`, `template`, `namespace`, `union`, `enum`, `typedef`, `sizeof`, `input`, `output`, `filter`, `image`, `sampler`, `fixed`, `volatile`, `public`, `static`, `extern`, `external`, `interface`, `long`, `short`, `double`, `half`, `unsigned`, `superp`, `inline`, `noinline`, etc. are all GLSL reserved words and **must never be used as variable or function names**! Common pitfall: naming a function `cast` for ray casting → compile failure. **Use compound names like `castRay`, `castShadow`, `shootRay` instead**. +- **IMPORTANT: GLSL strict typing**: float/int cannot be mixed. `if (x > 0)` for int, `if (y < 0.0)` for float. Comparing ivec3 members to float requires explicit conversion: `float(c.y) < height`. When getVoxel returns int, compare with `> 0` not `> 0.0`. Function parameter types must match exactly. +- **IMPORTANT: Vector dimension mismatch (vec2 vs vec3)**: `p.xz` returns `vec2` and **must never** be added to `vec3` or passed to functions expecting `vec3` parameters (e.g., `fbm(vec3)`, `noise(vec3)`)! Common error: `fbm(p.xz * 0.08 + vec3(...))` — `vec2 + vec3` compile failure. **Fix**: either use a `vec2` version of noise/fbm, or construct a full vec3: `fbm(vec3(p.xz * 0.08, p.y * 0.05))`. Similarly, `vec2` only has `.x`/`.y`, cannot access `.z`/`.w`. +- **IMPORTANT: length() / floating-point precision**: `length(ivec2)` must first convert to `vec2`: `length(vec2(d))`. Exact floating-point equality comparison almost never works; use range comparison: `floor(p.y) == floor(height)` + +# Voxel Rendering Skill + +## Use Cases +- Rendering discrete volumetric data on regular 3D grids (Minecraft-style worlds, medical volume data, architectural voxel models) +- Pixel-accurate block/cube scenes +- "Block art", "3D pixel art", "low-poly voxel" visual styles +- Real-time voxel scenes in pure fragment shader environments like ShaderToy +- Advanced lighting effects including shadows, AO, and global illumination + +## Core Principles + +The core of voxel rendering is the **DDA (Digital Differential Analyzer) ray traversal algorithm**: cast a ray from the camera through each pixel, stepping through the 3D grid cell by cell along the ray direction until hitting an occupied voxel. + +For ray `P(t) = rayPos + t * rayDir`, DDA maintains: +- **`mapPos`** = `floor(rayPos)`: current grid coordinate (integer) +- **`deltaDist`** = `abs(1.0 / rayDir)`: t cost to cross one cell +- **`sideDist`** = `(sign(rayDir) * (mapPos - rayPos) + sign(rayDir) * 0.5 + 0.5) * deltaDist`: t distance to the next boundary on each axis + +Each step advances along the axis with the smallest `sideDist`, updating `sideDist += deltaDist` and `mapPos += rayStep`. + +Normal on hit: `normal = -mask * rayStep` + +Face UV is obtained by projecting the hit point onto the two tangent axes of the hit face. + +## Implementation Steps + +### Step 1: Camera Ray Construction +```glsl +vec2 screenPos = (fragCoord.xy / iResolution.xy) * 2.0 - 1.0; +vec3 cameraDir = vec3(0.0, 0.0, 0.8); // Focal length; larger = narrower FOV +vec3 cameraPlaneU = vec3(1.0, 0.0, 0.0); +vec3 cameraPlaneV = vec3(0.0, 1.0, 0.0) * iResolution.y / iResolution.x; +vec3 rayDir = cameraDir + screenPos.x * cameraPlaneU + screenPos.y * cameraPlaneV; +vec3 rayPos = vec3(0.0, 2.0, -12.0); +``` + +### Step 2: DDA Initialization +```glsl +ivec3 mapPos = ivec3(floor(rayPos)); +vec3 rayStep = sign(rayDir); +vec3 deltaDist = abs(1.0 / rayDir); // When ray is normalized, equivalent to abs(1.0/rd), no length() needed +vec3 sideDist = (sign(rayDir) * (vec3(mapPos) - rayPos) + (sign(rayDir) * 0.5) + 0.5) * deltaDist; +``` + +### Step 3: DDA Traversal Loop (Branchless Version) +```glsl +#define MAX_RAY_STEPS 64 + +bvec3 mask; +for (int i = 0; i < MAX_RAY_STEPS; i++) { + if (getVoxel(mapPos)) break; + // Branchless axis selection + mask = lessThanEqual(sideDist.xyz, min(sideDist.yzx, sideDist.zxy)); + sideDist += vec3(mask) * deltaDist; + mapPos += ivec3(vec3(mask)) * ivec3(rayStep); +} +``` + +Alternative form (step version): +```glsl +vec3 mask = step(sideDist.xyz, sideDist.yzx) * step(sideDist.xyz, sideDist.zxy); +sideDist += mask * deltaDist; +mapPos += mask * rayStep; +``` + +### Step 4: Voxel Occupancy Function +```glsl +// Basic version: solid block (most common; use this when user asks for "voxel cube") +// IMPORTANT: Important: getVoxel receives ivec3, but all internal calculations must use float! +bool getVoxel(ivec3 c) { + vec3 p = vec3(c) + vec3(0.5); // ivec3 → vec3 conversion (required!) + float d = sdBox(p, vec3(6.0)); // Solid 12x12x12 cube + return d < 0.0; +} + +// Advanced version: SDF boolean operations (sphere carved from box = only corners remain) +bool getVoxelCarved(ivec3 c) { + vec3 p = vec3(c) + vec3(0.5); + float d = max(-sdSphere(p, 7.5), sdBox(p, vec3(6.0))); // box ∩ ¬sphere + return d < 0.0; +} + +// Advanced version: height map terrain with material IDs +// IMPORTANT: Key: all comparisons must use float! c.y is int and must be converted to float for comparison +// IMPORTANT: Important: must use range comparison, not exact equality (floating-point precision issues) +int getVoxelMaterial(ivec3 c) { + vec3 p = vec3(c); // ivec3 → vec3 conversion (required!) + float groundHeight = getTerrainHeight(p.xz); // p.xz is vec2, passes float parameters + if (float(c.y) < groundHeight) return 1; // int → float comparison + if (float(c.y) < groundHeight + 4.0) return 7; // int → float comparison + return 0; +} + +// Pure float version (simpler, recommended): +int getVoxelMaterial(vec3 c) { + float groundHeight = getTerrainHeight(c.xz); + // IMPORTANT: Use range comparison, never exact equality! + if (c.y >= groundHeight && c.y < groundHeight + 1.0) return 1; // Grass top layer + if (c.y >= groundHeight - 3.0 && c.y < groundHeight) return 2; // Dirt layer + if (c.y < groundHeight - 3.0) return 3; // Stone layer + return 0; +} + +// Advanced version: mountain terrain (height-based coloring: grass green → rock gray → snow white) +// IMPORTANT: Key 1: color thresholds must be based on heightRatio (normalized height 0~1), not absolute height! +// IMPORTANT: Key 2: maxH must match the actual maximum return value of getMountainHeight! +// If getMountainHeight returns at most 15.0, maxH must be 15.0, not arbitrarily 20.0 +// IMPORTANT: Key 3: threshold spacing must be large enough (at least 0.2), otherwise color bands are too narrow to see +// IMPORTANT: Key 4: grass area typically covers the largest terrain area (low elevation); set grass threshold high (0.4) to ensure green is clearly visible +float maxH = 15.0; // IMPORTANT: Must equal the actual max value of getMountainHeight! +int getMountainVoxel(vec3 c) { + float height = getMountainHeight(c.xz); // Returns 0 ~ maxH + if (c.y > height) return 0; // Air + float heightRatio = c.y / maxH; // Normalize to 0~1 + // IMPORTANT: Thresholds from low to high: grass < 0.4, rock 0.4~0.7, snow > 0.7 + if (heightRatio < 0.4) return 1; // Grass (green) — largest area + if (heightRatio < 0.7) return 2; // Rock (gray) + return 3; // Snow cap (white) +} +// IMPORTANT: Corresponding material colors must have sufficient saturation and clear contrast: +// mat==1: vec3(0.25, 0.55, 0.15) Grass green (saturated green, must not be grayish!) +// mat==2: vec3(0.5, 0.45, 0.4) Rock gray-brown +// mat==3: vec3(0.92, 0.93, 0.96) Snow white +// IMPORTANT: Lighting must not be too bright or it washes out colors! Sun intensity ≤ 2.0, sky light ≤ 1.0 +// IMPORTANT: Gamma correction pow(col, vec3(0.4545)) brightens dark colors and reduces saturation; +// if colors look grayish-white, make grass green more saturated: vec3(0.2, 0.5, 0.1) + +// IMPORTANT: Rotating objects: to rotate a voxel object, apply inverse rotation to the sample point in getVoxel! +// Do not rotate the camera to simulate object rotation (that only changes the viewpoint) +bool getVoxelRotating(ivec3 c) { + vec3 p = vec3(c) + vec3(0.5); + // Rotate around Y axis: apply inverse rotation to sample point + float angle = -iTime; // Negative sign = inverse transform + float s = sin(angle), co = cos(angle); + p.xz = vec2(p.x * co - p.z * s, p.x * s + p.z * co); + float d = sdBox(p, vec3(6.0)); // Rotated solid cube + return d < 0.0; +} +``` + +### Step 5: Face Shading (Normal + Base Color) +```glsl +vec3 normal = -vec3(mask) * rayStep; +vec3 color; +if (mask.x) color = vec3(0.5); // Side faces darkest +if (mask.y) color = vec3(1.0); // Top face brightest +if (mask.z) color = vec3(0.75); // Front/back faces medium +fragColor = vec4(color, 1.0); +``` + +### Step 6: Precise Hit Position and Face UV +```glsl +float t = dot(sideDist - deltaDist, vec3(mask)); +vec3 hitPos = rayPos + rayDir * t; +vec3 uvw = hitPos - vec3(mapPos); +vec2 uv = vec2(dot(vec3(mask) * uvw.yzx, vec3(1.0)), + dot(vec3(mask) * uvw.zxy, vec3(1.0))); +``` + +### Step 7: Neighbor Voxel AO +```glsl +float vertexAo(vec2 side, float corner) { + return (side.x + side.y + max(corner, side.x * side.y)) / 3.0; +} + +vec4 voxelAo(vec3 pos, vec3 d1, vec3 d2) { + vec4 side = vec4( + getVoxel(pos + d1), getVoxel(pos + d2), + getVoxel(pos - d1), getVoxel(pos - d2)); + vec4 corner = vec4( + getVoxel(pos + d1 + d2), getVoxel(pos - d1 + d2), + getVoxel(pos - d1 - d2), getVoxel(pos + d1 - d2)); + vec4 ao; + ao.x = vertexAo(side.xy, corner.x); + ao.y = vertexAo(side.yz, corner.y); + ao.z = vertexAo(side.zw, corner.z); + ao.w = vertexAo(side.wx, corner.w); + return 1.0 - ao; +} + +// Bilinear interpolation +vec4 ambient = voxelAo(mapPos - rayStep * mask, mask.zxy, mask.yzx); +float ao = mix(mix(ambient.z, ambient.w, uv.x), mix(ambient.y, ambient.x, uv.x), uv.y); +ao = pow(ao, 1.0 / 3.0); // Gamma correction to control AO intensity +``` + +### Step 8: DDA Shadow Ray +```glsl +// IMPORTANT: Shadow steps must be capped at 16; total main ray + shadow ray steps should not exceed 80 +#define MAX_SHADOW_STEPS 16 + +float castShadow(vec3 ro, vec3 rd) { + vec3 pos = floor(ro); + vec3 ri = 1.0 / rd; + vec3 rs = sign(rd); + vec3 dis = (pos - ro + 0.5 + rs * 0.5) * ri; + for (int i = 0; i < MAX_SHADOW_STEPS; i++) { + if (getVoxel(ivec3(pos))) return 0.0; + vec3 mm = step(dis.xyz, dis.yzx) * step(dis.xyz, dis.zxy); + dis += mm * rs * ri; + pos += mm * rs; + } + return 1.0; +} + +vec3 sundir = normalize(vec3(-0.5, 0.6, 0.7)); +float shadow = castShadow(hitPos + normal * 0.01, sundir); +float diffuse = max(dot(normal, sundir), 0.0) * shadow; +``` + +## Complete Code Template + +```glsl +// === Voxel Rendering - Complete ShaderToy Template === +// Includes: DDA traversal, face shading, neighbor AO, hard shadows + +// IMPORTANT: Performance critical: SwiftShader software renderer (headless browser evaluation environment) cannot handle too many loop iterations +// Default 64+16=80 steps, suitable for most scenes. Simple scenes (single cube) can increase to 96+24 +// Multi-building/character/Minecraft scenes must keep 64+16 or lower! +#define MAX_RAY_STEPS 64 +#define MAX_SHADOW_STEPS 16 +#define GRID_SIZE 16.0 + +// ---- Math Utilities ---- +float sdSphere(vec3 p, float r) { return length(p) - r; } +float sdBox(vec3 p, vec3 b) { + vec3 d = abs(p) - b; + return min(max(d.x, max(d.y, d.z)), 0.0) + length(max(d, 0.0)); +} +float hash31(vec3 n) { return fract(sin(dot(n, vec3(1.0, 113.0, 257.0))) * 43758.5453); } + +vec2 rotate2d(vec2 v, float a) { + float s = sin(a), c = cos(a); + return vec2(v.x * c - v.y * s, v.y * c + v.x * s); +} + +// ---- Voxel Scene Definition ---- +// IMPORTANT: Default solid cube. Use sdBox for "voxel cube"; add SDF boolean ops for carved/sculpted shapes +int getVoxel(vec3 c) { + vec3 p = c + 0.5; + float d = sdBox(p, vec3(6.0)); // Solid 12x12x12 block + if (d < 0.0) { + if (p.y < -3.0) return 2; + return 1; + } + return 0; +} + +// ---- Neighbor AO ---- +float getOccupancy(vec3 c) { return float(getVoxel(c) > 0); } + +float vertexAo(vec2 side, float corner) { + return (side.x + side.y + max(corner, side.x * side.y)) / 3.0; +} + +vec4 voxelAo(vec3 pos, vec3 d1, vec3 d2) { + vec4 side = vec4( + getOccupancy(pos + d1), getOccupancy(pos + d2), + getOccupancy(pos - d1), getOccupancy(pos - d2)); + vec4 corner = vec4( + getOccupancy(pos + d1 + d2), getOccupancy(pos - d1 + d2), + getOccupancy(pos - d1 - d2), getOccupancy(pos + d1 - d2)); + vec4 ao; + ao.x = vertexAo(side.xy, corner.x); + ao.y = vertexAo(side.yz, corner.y); + ao.z = vertexAo(side.zw, corner.z); + ao.w = vertexAo(side.wx, corner.w); + return 1.0 - ao; +} + +// ---- DDA Traversal Core ---- +struct HitInfo { + bool hit; + float t; + vec3 pos; + vec3 normal; + vec3 mapPos; + vec2 uv; + int mat; +}; + +HitInfo castRay(vec3 ro, vec3 rd, int maxSteps) { + HitInfo info; + info.hit = false; + info.t = 0.0; + + vec3 mapPos = floor(ro); + vec3 rayStep = sign(rd); + vec3 deltaDist = abs(1.0 / rd); + vec3 sideDist = (rayStep * (mapPos - ro) + rayStep * 0.5 + 0.5) * deltaDist; + vec3 mask = vec3(0.0); + + for (int i = 0; i < maxSteps; i++) { + int vox = getVoxel(mapPos); + if (vox > 0) { + info.hit = true; + info.mat = vox; + info.normal = -mask * rayStep; + info.mapPos = mapPos; + info.t = dot(sideDist - deltaDist, mask); + info.pos = ro + rd * info.t; + vec3 uvw = info.pos - mapPos; + info.uv = vec2(dot(mask * uvw.yzx, vec3(1.0)), + dot(mask * uvw.zxy, vec3(1.0))); + return info; + } + mask = step(sideDist.xyz, sideDist.yzx) * step(sideDist.xyz, sideDist.zxy); + sideDist += mask * deltaDist; + mapPos += mask * rayStep; + } + return info; +} + +// ---- Shadow Ray ---- +// IMPORTANT: Shadow steps at 16 (combined with main ray 64 = 80, within SwiftShader safe range) +float castShadow(vec3 ro, vec3 rd) { + vec3 pos = floor(ro); + vec3 ri = 1.0 / rd; + vec3 rs = sign(rd); + vec3 dis = (pos - ro + 0.5 + rs * 0.5) * ri; + for (int i = 0; i < MAX_SHADOW_STEPS; i++) { + // IMPORTANT: getVoxel returns int; comparison must use int constant (0), not float (0.0) + if (getVoxel(pos) > 0) return 0.0; + vec3 mm = step(dis.xyz, dis.yzx) * step(dis.xyz, dis.zxy); + dis += mm * rs * ri; + pos += mm * rs; + } + return 1.0; +} + +// ---- Material Colors ---- +// IMPORTANT: Texture coloring key: "low saturation" does not mean "near white/gray"! +// Low saturation = colorful but not vivid, must retain clear hue differences (e.g., brick red 0.55,0.35,0.3 not gray-white 0.8,0.8,0.8) +// Brick/stone textures: use UV periodic patterns (mortar lines = dark lines), never use solid colors! +vec3 getMaterialColor(int mat, vec2 uv) { + vec3 col = vec3(0.6); + if (mat == 1) col = vec3(0.7, 0.7, 0.75); + if (mat == 2) col = vec3(0.4, 0.55, 0.3); + float checker = mod(floor(uv.x * 4.0) + floor(uv.y * 4.0), 2.0); + col *= 0.85 + 0.15 * checker; + return col; +} + +// ---- Brick/Stone Texture Coloring (use this to replace getMaterialColor when user requests "brick texture") ---- +// IMPORTANT: Key: brick texture = UV periodic pattern (staggered rows + mortar dark lines), not solid color! +vec3 getBrickColor(vec2 uv, vec3 baseColor, vec3 mortarColor) { + vec2 brickUV = uv * vec2(4.0, 8.0); + float row = floor(brickUV.y); + brickUV.x += mod(row, 2.0) * 0.5; // Staggered row offset + vec2 f = fract(brickUV); + float mortar = step(f.x, 0.06) + step(f.y, 0.08); // Mortar joints + mortar = clamp(mortar, 0.0, 1.0); + float noise = fract(sin(dot(floor(brickUV), vec2(12.9898, 78.233))) * 43758.5453); + vec3 brickVariation = baseColor * (0.85 + 0.3 * noise); // Slight color variation per brick + return mix(brickVariation, mortarColor, mortar); +} +// Usage example (maze walls): +// if (mat == 1) col = getBrickColor(uv, vec3(0.55, 0.35, 0.3), vec3(0.4, 0.38, 0.35)); // Brick red + mortar +// if (mat == 2) col = getBrickColor(uv, vec3(0.5, 0.48, 0.42), vec3(0.35, 0.33, 0.3)); // Gray stone brick + +// ---- Main Function ---- +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 screenPos = (fragCoord.xy / iResolution.xy) * 2.0 - 1.0; + screenPos.x *= iResolution.x / iResolution.y; + + vec3 ro = vec3(0.0, 2.0 * sin(iTime * 0.5), -12.0); + vec3 forward = vec3(0.0, 0.0, 0.8); + vec3 rd = normalize(forward + vec3(screenPos, 0.0)); + + ro.xz = rotate2d(ro.xz, iTime * 0.3); + rd.xz = rotate2d(rd.xz, iTime * 0.3); + + vec3 sunDir = normalize(vec3(-0.5, 0.6, 0.7)); + vec3 skyColor = vec3(0.6, 0.75, 0.9); + + HitInfo hit = castRay(ro, rd, MAX_RAY_STEPS); + + vec3 col; + if (hit.hit) { + vec3 matCol = getMaterialColor(hit.mat, hit.uv); + + vec3 mask = abs(hit.normal); + vec4 ambient = voxelAo(hit.mapPos, mask.zxy, mask.yzx); + float ao = mix( + mix(ambient.z, ambient.w, hit.uv.x), + mix(ambient.y, ambient.x, hit.uv.x), + hit.uv.y); + ao = pow(ao, 0.5); + + float shadow = castShadow(hit.pos + hit.normal * 0.01, sunDir); + + float diff = max(dot(hit.normal, sunDir), 0.0); + float sky = 0.5 + 0.5 * hit.normal.y; + + vec3 lighting = vec3(0.0); + // IMPORTANT: Mountain/terrain scenes: sun light ≤ 2.0, sky light ≤ 1.0; too bright washes out material color differences + lighting += 2.0 * diff * vec3(1.0, 0.95, 0.8) * shadow; + lighting += 1.0 * sky * skyColor; + lighting *= ao; + + col = matCol * lighting; + + // IMPORTANT: Fog: coefficient should not be too large, otherwise nearby objects get swallowed into pure sky color + // 0.0002 suits GRID_SIZE=16 scenes; use smaller coefficients for larger scenes + float fog = 1.0 - exp(-0.0002 * hit.t * hit.t); + col = mix(col, skyColor, clamp(fog, 0.0, 0.7)); // Clamp prevents objects from disappearing entirely + } else { + col = skyColor - rd.y * 0.2; + } + + col = pow(clamp(col, 0.0, 1.0), vec3(0.4545)); + fragColor = vec4(col, 1.0); +} +``` + +## Common Variants + +### Variant 1: Glowing Voxels (Glow Accumulation) +Accumulate distance-based glow values during DDA traversal; produces semi-transparent glow even on miss. +```glsl +float glow = 0.0; +for (int i = 0; i < MAX_RAY_STEPS; i++) { + float d = sdSomeShape(vec3(mapPos)); + glow += 0.015 / (0.01 + d * d); + if (d < 0.0) break; + // ... normal DDA stepping ... +} +vec3 col = baseColor + glow * vec3(0.4, 0.6, 1.0); +``` + +### Variant 2: Rounded Voxels (Intra-voxel SDF Refinement) +After DDA hit, perform SDF ray march inside the voxel to render rounded blocks. +```glsl +float id = hash31(mapPos); +float w = 0.05 + 0.35 * id; + +float sdRoundedBox(vec3 p, float w) { + return length(max(abs(p) - 0.5 + w, 0.0)) - w; +} + +vec3 localP = hitPos - mapPos - 0.5; +for (int j = 0; j < 6; j++) { + float h = sdRoundedBox(localP, w); + if (h < 0.025) break; + localP += rd * max(0.0, h); +} +``` + +### Variant 3: Hybrid SDF-Voxel Traversal +SDF sphere-tracing with large steps at distance, switching to precise DDA near the surface. +```glsl +#define VOXEL_SIZE 0.0625 +#define SWITCH_DIST (VOXEL_SIZE * 1.732) + +bool useVoxel = false; +for (int i = 0; i < MAX_STEPS; i++) { + vec3 pos = ro + rd * t; + float d = mapSDF(useVoxel ? voxelCenter : pos); + if (!useVoxel) { + t += d; + if (d < SWITCH_DIST) { useVoxel = true; voxelPos = getVoxelPos(pos); } + } else { + if (d < 0.0) break; + if (d > SWITCH_DIST) { useVoxel = false; t += d; continue; } + vec3 exitT = (voxelPos - ro * ird + ird * VOXEL_SIZE * 0.5); + // ... select minimum axis to advance ... + } +} +``` + +### Variant 4: Voxel Cone Tracing +Build multi-level mipmaps, cast cone-shaped rays from hit points for global illumination. +```glsl +vec4 traceCone(vec3 origin, vec3 dir, float coneRatio) { + vec4 light = vec4(0.0); + float t = 1.0; + for (int i = 0; i < 58; i++) { + vec3 sp = origin + dir * t; + float diameter = max(1.0, t * coneRatio); + float lod = log2(diameter); + vec4 sample = voxelFetch(sp, lod); + light += sample * (1.0 - light.w); + t += diameter; + } + return light; +} +``` + +### Variant 5: PBR Lighting + Multi-Bounce Reflection +GGX BRDF replacing Lambert, with metallic/roughness parameters; cast a second DDA ray for reflections. +```glsl +float ggxDiffuse(float NoL, float NoV, float LoH, float roughness) { + float FD90 = 0.5 + 2.0 * roughness * LoH * LoH; + float a = 1.0 + (FD90 - 1.0) * pow(1.0 - NoL, 5.0); + float b = 1.0 + (FD90 - 1.0) * pow(1.0 - NoV, 5.0); + return a * b / 3.14159; +} + +vec3 rd2 = reflect(rd, normal); +HitInfo reflHit = castRay(hitPos + normal * 0.001, rd2, 64); +vec3 reflColor = reflHit.hit ? shade(reflHit) : skyColor; + +float fresnel = 0.04 + 0.96 * pow(1.0 - max(dot(normal, -rd), 0.0), 5.0); +col += fresnel * reflColor; +``` + +### Variant 6: Voxel Water Scene (Water + Underwater Voxels) +Water surface ripple reflections, underwater refraction, sand and seaweed for a complete water scene. +```glsl +float waterY = 0.0; + +// Underwater voxel scene definition (sand + seaweed) +// IMPORTANT: All coordinate operations must use correct vector dimensions! +// c.xz returns vec2, only has .x/.y components, cannot use .z! +int getVoxel(vec3 c) { + float sandHeight = -3.0 + 0.5 * sin(c.x * 0.3) * cos(c.z * 0.4); + if (c.y < sandHeight) return 1; // Sand interior + if (c.y < sandHeight + 1.0) return 2; // Sand surface + // Seaweed: only grows underwater, above sand + float grassHash = fract(sin(dot(floor(c.xz), vec2(12.9898, 78.233))) * 43758.5453); + // IMPORTANT: floor(c.xz) is vec2; the second argument to dot() must also be vec2 + if (grassHash > 0.85 && c.y >= sandHeight + 1.0 && c.y < sandHeight + 1.0 + 3.0 * grassHash) { + return 3; // Seaweed + } + return 0; +} + +// Handle water surface in main rendering +float tWater = (waterY - ro.y) / rd.y; +bool hitWater = tWater > 0.0 && (tWater < hit.t || !hit.hit); + +if (hitWater) { + vec3 waterPos = ro + rd * tWater; + vec3 waterNormal = vec3(0.0, 1.0, 0.0); + // IMPORTANT: waterPos.xz is vec2; access with .x/.y (not .x/.z) + vec2 waveXZ = waterPos.xz; // vec2: waveXZ.x = worldX, waveXZ.y = worldZ + waterNormal.x += 0.05 * sin(waveXZ.x * 3.0 + iTime); + waterNormal.z += 0.05 * cos(waveXZ.y * 2.0 + iTime * 0.7); + waterNormal = normalize(waterNormal); + + float fresnel = 0.04 + 0.96 * pow(1.0 - max(dot(waterNormal, -rd), 0.0), 5.0); + + // Reflection + vec3 reflDir = reflect(rd, waterNormal); + HitInfo reflHit = castRay(waterPos + waterNormal * 0.01, reflDir, 64); + vec3 reflCol = reflHit.hit ? getMaterialColor(reflHit.mat, reflHit.uv) : skyColor; + + // Refraction (underwater voxels: sand, seaweed) + vec3 refrDir = refract(rd, waterNormal, 1.0 / 1.33); + HitInfo refrHit = castRay(waterPos - waterNormal * 0.01, refrDir, 64); + vec3 refrCol; + if (refrHit.hit) { + vec3 matCol = getMaterialColor(refrHit.mat, refrHit.uv); + float underwaterDist = length(refrHit.pos - waterPos); + refrCol = mix(matCol, vec3(0.0, 0.15, 0.3), 1.0 - exp(-0.1 * underwaterDist)); + } else { + refrCol = vec3(0.0, 0.1, 0.3); + } + + col = mix(refrCol, reflCol, fresnel); + col = mix(col, vec3(0.0, 0.3, 0.5), 0.2); +} +``` + +### Variant 7: Rotating Voxel Objects +Rotate voxel objects as a whole. Core: apply inverse rotation to sample points in getVoxel. +```glsl +// IMPORTANT: Correct way to rotate objects: apply inverse rotation to sample coordinates in getVoxel +// Wrong approach: only rotate the camera (that just changes the viewpoint, not the object) +int getVoxel(vec3 c) { + vec3 p = c + 0.5; + // Rotate around Y axis + float angle = -iTime * 0.5; + float s = sin(angle), co = cos(angle); + p.xz = vec2(p.x * co - p.z * s, p.x * s + p.z * co); + // Can also rotate around multiple axes: + // p.yz = vec2(p.y * co2 - p.z * s2, p.y * s2 + p.z * co2); // X axis rotation + float d = sdBox(p, vec3(6.0)); + if (d < 0.0) return 1; + return 0; +} +``` + +### Variant 8: Indoor/Cave/Enclosed Scenes (Point Lights + High Ambient Lighting) +Indoor, cave, underground, sci-fi base, and other enclosed or semi-enclosed scenes require point lights and high ambient lighting. +```glsl +// IMPORTANT: Key points for enclosed/semi-enclosed scenes (caves, interiors, sci-fi bases, mazes, etc.): +// 1. Camera must be placed inside the cavity (a position where getVoxel returns 0) +// 2. Must use point lights, not just directional light (directional light blocked by walls/ceiling = total darkness!) +// 3. Ambient light must be high enough (at least 0.2-0.3) to prevent scene from being too dark to see details +// 4. Can use multiple point lights + emissive voxels to simulate torches/fluorescence/holographic displays +// 5. Sci-fi scene metallic walls need bright enough light sources to show reflections +// 6. Emissive elements (holographic screens, indicator lights, magic circles) use emissive materials: add emissive color directly to lighting + +// Cave scene: cavity = area where getVoxel returns 0 +// IMPORTANT: Cave/terrain noise functions must respect vector dimensions! +// p.xz is vec2; if noise/fbm function takes vec3, construct a full vec3: +// Correct: fbm(vec3(p.xz, p.y * 0.5)) or use vec2 version of noise +// Wrong: fbm(p.xz + vec3(...)) ← vec2 + vec3 compile failure! +int getVoxel(vec3 c) { + float cave = sdSphere(c + 0.5, 12.0); + // IMPORTANT: For noise-carved detail, use c's components directly (all float) + cave += 2.0 * sin(c.x * 0.3) * sin(c.y * 0.4) * sin(c.z * 0.35); + if (cave > 0.0) return 1; // Rock wall + return 0; // Cavity (camera goes here) +} + +// Point light attenuation +vec3 pointLightPos = vec3(0.0, 3.0, 0.0); +vec3 toLight = pointLightPos - hit.pos; +float lightDist = length(toLight); +vec3 lightDir = toLight / lightDist; +float attenuation = 1.0 / (1.0 + 0.1 * lightDist + 0.01 * lightDist * lightDist); + +float diff = max(dot(hit.normal, lightDir), 0.0); +float shadow = castShadow(hit.pos + hit.normal * 0.01, lightDir); + +vec3 lighting = vec3(0.0); +// IMPORTANT: High ambient light to prevent total darkness (required for enclosed scenes! at least 0.2) +lighting += vec3(0.25, 0.22, 0.2); // Warm ambient light +lighting += 3.0 * diff * attenuation * vec3(1.0, 0.8, 0.5) * shadow; // Point light + +// Multiple torches/emissive objects (use sin for flicker animation) +vec3 torch1 = vec3(5.0, 2.0, 3.0); +vec3 torch2 = vec3(-4.0, 1.0, -5.0); +float flicker1 = 0.8 + 0.2 * sin(iTime * 5.0 + 1.0); +float flicker2 = 0.8 + 0.2 * sin(iTime * 4.3 + 2.7); +lighting += calcPointLight(hit.pos, hit.normal, torch1, vec3(1.0, 0.6, 0.2)) * flicker1; +lighting += calcPointLight(hit.pos, hit.normal, torch2, vec3(0.2, 1.0, 0.5)) * flicker2; + +// Emissive materials (holographic displays, fluorescent moss, indicator lights, magic circles, etc.) +// IMPORTANT: Emissive colors are added directly to lighting, unaffected by shadows +if (hit.mat == 2) { + lighting += vec3(0.1, 0.4, 0.15); // Fluorescent moss (faint green) +} +if (hit.mat == 3) { + float pulse = 0.7 + 0.3 * sin(iTime * 2.0); + lighting += vec3(0.2, 0.6, 1.0) * pulse; // Blue pulse light +} + +col = matCol * lighting; +``` + +### Variant 9: Voxel Character Animation +Simple voxel character animation using time-driven offsets and rotations. +```glsl +// IMPORTANT: Voxel character animation core approach: +// 1. Split the character into multiple body parts (head, torso, left arm, right arm, left leg, right leg) +// 2. Each part is an sdBox with independent offset/rotation parameters +// 3. iTime drives limb swinging (sin/cos periodic motion) +// 4. Combine all parts using SDF min() +// IMPORTANT: SwiftShader performance critical: character function is called at every DDA step! +// Must add AABB bounding box check in getVoxel: first check if c is near the character, +// skip sdBox calculations for that character if not nearby. Otherwise frame timeout → black screen +// Reduce MAX_RAY_STEPS to 64, MAX_SHADOW_STEPS to 16 + +int getCharacter(vec3 p, vec3 charPos, float animPhase) { + vec3 lp = p - charPos; + float limbSwing = sin(iTime * 4.0 + animPhase) * 0.5; + + // Torso + float body = sdBox(lp - vec3(0, 3, 0), vec3(1.5, 2.0, 1.0)); + // Head + float head = sdBox(lp - vec3(0, 6, 0), vec3(1.2, 1.2, 1.2)); + + // Arm swing (offset y coordinate around shoulder joint to simulate rotation) + vec3 armOffset = vec3(0, limbSwing * 2.0, limbSwing); + float leftArm = sdBox(lp - vec3(-2.5, 3, 0) - armOffset, vec3(0.5, 2.0, 0.5)); + float rightArm = sdBox(lp - vec3(2.5, 3, 0) + armOffset, vec3(0.5, 2.0, 0.5)); + + // Alternating leg swing + vec3 legOffset = vec3(0, 0, limbSwing * 1.5); + float leftLeg = sdBox(lp - vec3(-0.7, 0, 0) - legOffset, vec3(0.5, 1.5, 0.5)); + float rightLeg = sdBox(lp - vec3(0.7, 0, 0) + legOffset, vec3(0.5, 1.5, 0.5)); + + float d = min(body, min(head, min(leftArm, min(rightArm, min(leftLeg, rightLeg))))); + if (d < 0.0) { + if (head < 0.0) return 10; // Head (skin color) + if (leftArm < 0.0 || rightArm < 0.0) return 11; // Arms + return 12; // Torso/legs + } + return 0; +} + +// Combine scene + characters in getVoxel +// IMPORTANT: Must add AABB bounding box early exit! Character sdBox calculations are expensive +int getVoxel(vec3 c) { + // Scene (floor, walls, etc.) + int scene = getSceneVoxel(c); + if (scene > 0) return scene; + // IMPORTANT: AABB check: only call getCharacter near the character + // Character 1: warrior (at position (5,0,0)), bounding box ±5 cells + if (abs(c.x - 5.0) < 5.0 && c.y >= 0.0 && c.y < 10.0 && abs(c.z) < 5.0) { + int char1 = getCharacter(c, vec3(5, 0, 0), 0.0); + if (char1 > 0) return char1; + } + // Character 2: mage (at position (-5,0,3)), bounding box ±5 cells + if (abs(c.x + 5.0) < 5.0 && c.y >= 0.0 && c.y < 10.0 && abs(c.z - 3.0) < 5.0) { + int char2 = getCharacter(c, vec3(-5, 0, 3), 3.14); + if (char2 > 0) return char2; + } + return 0; +} +``` + +### Variant 10: Waterfall / Flowing Water Particle Effects +Dynamic waterfall, splash particles, water mist effects. Core: time-offset noise simulates water flow, hashed particles simulate splashes, exponential decay simulates mist. +```glsl +// IMPORTANT: Key points for waterfall/flowing water/particle effects: +// 1. Waterfall stream: noise + iTime vertical offset simulates water column flowing down +// 2. Splash particles: hash-distributed voxels at the bottom, positions change with iTime to simulate splashing +// 3. Water mist: semi-transparent accumulation (reduced alpha) or density field at the bottom simulates mist diffusion +// 4. Waterfall must have a clear high point (cliff/rock wall) and low point (pool), drop ≥ 10 cells +// 5. Water stream material uses light blue-white + brightness flicker to simulate flowing water feel + +float hash21(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } + +int getVoxel(vec3 c) { + // Cliff rock walls (both sides + back) + if (c.x < -5.0 || c.x > 5.0) { + if (c.y < 15.0 && c.z > -3.0 && c.z < 3.0) return 1; // Rock + } + if (c.z > 2.0 && c.y < 15.0 && abs(c.x) < 6.0) return 1; // Back wall + + // Cliff top platform + if (c.y >= 13.0 && c.y < 15.0 && c.z > -1.0 && c.z < 3.0 && abs(c.x) < 5.0) return 1; + + // Bottom pool floor + if (c.y < -2.0 && abs(c.x) < 8.0 && c.z > -6.0 && c.z < 3.0) return 2; // Pool bottom + + // IMPORTANT: Waterfall stream: narrow band x ∈ [-2, 2], falling from y=13 to y=0 + // Use iTime offset on y-coordinate noise to simulate downward water flow + if (abs(c.x) < 2.0 && c.y >= 0.0 && c.y < 13.0 && c.z > -1.0 && c.z < 1.0) { + float flowNoise = hash21(vec2(floor(c.x), floor(c.y - iTime * 8.0))); + if (flowNoise > 0.25) return 3; // Water (gaps simulate translucent water curtain) + } + + // IMPORTANT: Splash particles: bottom y ∈ [-1, 3], x ∈ [-4, 4] + // Use hash + iTime to generate randomly bouncing voxel particles + if (c.y >= -1.0 && c.y < 3.0 && abs(c.x) < 4.0 && c.z > -3.0 && c.z < 2.0) { + float t = iTime * 3.0; + float particleHash = hash21(vec2(floor(c.x * 2.0), floor(c.z * 2.0) + floor(t))); + float yOffset = fract(t + particleHash) * 3.0; // Particle upward trajectory + if (abs(c.y - yOffset) < 0.6 && particleHash > 0.7) return 4; // Splash particle + } + + // IMPORTANT: Water mist: bottom y ∈ [-1, 2], wider range than splashes + // Density decreases with height and distance from waterfall center + if (c.y >= -1.0 && c.y < 2.0 && abs(c.x) < 6.0 && c.z > -5.0 && c.z < 3.0) { + float distFromCenter = length(vec2(c.x, c.z)); + float mistDensity = exp(-0.15 * distFromCenter) * exp(-0.5 * max(c.y, 0.0)); + float mistNoise = hash21(vec2(floor(c.x * 0.5 + iTime * 0.5), floor(c.z * 0.5))); + if (mistNoise < mistDensity * 0.8) return 5; // Water mist + } + + return 0; +} + +// Material colors +vec3 getMaterialColor(int mat, vec2 uv) { + if (mat == 1) return vec3(0.45, 0.4, 0.35); // Rock + if (mat == 2) return vec3(0.35, 0.3, 0.25); // Pool bottom + if (mat == 3) { // Water stream (shimmering blue-white) + float shimmer = 0.8 + 0.2 * sin(uv.y * 20.0 + iTime * 10.0); + return vec3(0.6, 0.8, 1.0) * shimmer; + } + if (mat == 4) return vec3(0.85, 0.92, 1.0); // Splash (bright white) + if (mat == 5) return vec3(0.7, 0.82, 0.9); // Water mist (pale blue-white) + return vec3(0.5); +} + +// IMPORTANT: Water mist material needs special lighting: high emissive + translucent feel +// During shading: +if (hit.mat == 5) { + lighting += vec3(0.4, 0.5, 0.6); // Water mist emissive (unaffected by shadows) +} + +// Camera: side angle slightly elevated, showing the full waterfall (top to bottom + bottom splashes and mist) +// ro = vec3(12.0, 10.0, -10.0), lookAt = vec3(0.0, 6.0, 0.0) +``` + +### Variant 11: Multi-Building / Town / Minecraft-Style Scenes (Multi-Structure Town Composition) +Towns, villages, Minecraft-style worlds, and other scenes requiring multiple discrete structures (houses, trees, lampposts, etc.) placed on the ground. +**IMPORTANT: "Minecraft-like voxel scene" = multi-building scene; must follow the performance constraints of this template!** +```glsl +// IMPORTANT: Key points for multi-building scenes: +// 1. Define the ground first (height map or flat plane), ensure ground getVoxel returns correct material +// 2. Each building uses an independent helper function, receiving local coordinates, returning material ID +// 3. In getVoxel, check each building sequentially (using offset coordinates), return on first hit +// 4. Camera must be outside the scene facing the center, far enough to see the full view +// 5. IMPORTANT: Building coordinate ranges must be within DDA traversal range (MAX_RAY_STEPS * cell ≈ reachable distance) +// 6. IMPORTANT: Scene range should not be too large! Concentrate all buildings within -20~20 range, camera 30-50 cells away +// 7. IMPORTANT: SwiftShader performance critical: getVoxel must have AABB bounding box early exit! +// Above ground (c.y > 0), check AABB range first; return 0 immediately if outside building area +// Otherwise every DDA step checks all buildings → frame timeout → black screen / only sky renders +// 8. IMPORTANT: MAX_RAY_STEPS reduced to 64, MAX_SHADOW_STEPS to 16 (complex getVoxel requires lower step counts) + +// Single house: width w, depth d, height h, with triangular roof +int makeHouse(vec3 p, float w, float d, float h, int wallMat, int roofMat) { + // Walls + if (p.x >= 0.0 && p.x < w && p.z >= 0.0 && p.z < d && p.y >= 0.0 && p.y < h) { + return wallMat; + } + // Triangular roof: starts from wall top, x range narrows by 1 per level + float roofY = p.y - h; + float roofInset = roofY; // Inset by 1 cell per level + if (roofY >= 0.0 && roofY < w * 0.5 + && p.x >= roofInset && p.x < w - roofInset + && p.z >= 0.0 && p.z < d) { + return roofMat; + } + return 0; +} + +// Tree: trunk + spherical canopy +int makeTree(vec3 p, float trunkH, float crownR, int trunkMat, int leafMat) { + // Trunk (1x1 column) + if (p.x >= -0.5 && p.x < 0.5 && p.z >= -0.5 && p.z < 0.5 + && p.y >= 0.0 && p.y < trunkH) { + return trunkMat; + } + // Spherical canopy + vec3 crownCenter = vec3(0.0, trunkH + crownR * 0.5, 0.0); + if (length(p - crownCenter) < crownR) { + return leafMat; + } + return 0; +} + +// Lamppost: thin pole + glowing top block +int makeLamp(vec3 p, float h, int poleMat, int lightMat) { + if (p.x >= -0.3 && p.x < 0.3 && p.z >= -0.3 && p.z < 0.3 + && p.y >= 0.0 && p.y < h) { + return poleMat; // Pole + } + if (p.x >= -0.5 && p.x < 0.5 && p.z >= -0.5 && p.z < 0.5 + && p.y >= h && p.y < h + 1.0) { + return lightMat; // Lamp head (emissive) + } + return 0; +} + +int getVoxel(vec3 c) { + // 1. Ground (y < 0 is underground, y == 0 layer is surface) + if (c.y < -1.0) return 0; + if (c.y < 0.0) return 1; // Ground (dirt/grass) + + // 2. Road (along z direction, x range -2~2) + if (c.y < 1.0 && abs(c.x) < 2.0) return 2; // Road surface + + // IMPORTANT: AABB bounding box early exit (required for SwiftShader!) + // All buildings are within x:-15~15, y:0~12, z:-5~15 + // Return 0 immediately outside this range, avoiding per-building checks + if (c.x < -15.0 || c.x > 15.0 || c.y > 12.0 || c.z < -5.0 || c.z > 15.0) return 0; + + // 3. Place buildings (each with offset coordinates) + // IMPORTANT: House width/height must be ≥ 5 cells, otherwise they look like dots from far away! Use bright material colors + int m; + + // House A: position (5, 0, 3), width 6, depth 5, height 5 + m = makeHouse(c - vec3(5.0, 0.0, 3.0), 6.0, 5.0, 5.0, 3, 4); + if (m > 0) return m; + + // House B: position (-10, 0, 2), width 7, depth 5, height 5 + m = makeHouse(c - vec3(-10.0, 0.0, 2.0), 7.0, 5.0, 5.0, 5, 4); + if (m > 0) return m; + + // Tree: position (0, 0, 8) + m = makeTree(c - vec3(0.0, 0.0, 8.0), 4.0, 2.5, 6, 7); + if (m > 0) return m; + + // Lamppost: position (3, 0, 0) + m = makeLamp(c - vec3(3.0, 0.0, 0.0), 5.0, 8, 9); + if (m > 0) return m; + + return 0; +} + +// IMPORTANT: Camera setup: must be far enough to overlook the entire town +// Recommended: ro = vec3(0, 15, -35), looking at scene center vec3(0, 3, 5) +vec3 ro = vec3(0.0, 15.0, -35.0); +vec3 lookAt = vec3(0.0, 3.0, 5.0); +vec3 forward = normalize(lookAt - ro); +vec3 right = normalize(cross(forward, vec3(0, 1, 0))); +vec3 up = cross(right, forward); +vec3 rd = normalize(forward * 0.8 + right * screenPos.x + up * screenPos.y); + +// IMPORTANT: Sunset/side-lit scene key: when light comes from the side or at low angle, building fronts may be completely backlit turning into black silhouettes! +// Must satisfy all: (1) ambient light ≥ 0.3 (prevent backlit faces from going black); (2) house walls use bright materials (e.g., light yellow 0.85,0.75,0.55) +// (3) house dimensions must not be too small (width/height ≥ 5 cells), otherwise they look like dots from far away +vec3 sunDir = normalize(vec3(-0.8, 0.3, 0.5)); // Sunset low angle +vec3 sunColor = vec3(1.0, 0.6, 0.3); // Warm orange +vec3 ambientColor = vec3(0.35, 0.3, 0.4); // IMPORTANT: High ambient light (≥0.3) to prevent silhouettes +// lighting = ambientColor + diff * sunColor * shadow; +``` + +## Performance & Composition + +**Performance Tips:** +- Early exit: break immediately when `mapPos` exceeds scene bounds +- Shadow ray steps of 16-24 are sufficient +- Use SDF sphere-tracing with large steps in open areas, switch to DDA near surfaces +- Material queries, AO, normals, etc. are only computed after hit +- Replace procedural voxel queries with `texelFetch` texture sampling +- Multi-frame accumulation + reprojection for low-noise results +- **IMPORTANT: MAX_RAY_STEPS defaults to 64, MAX_SHADOW_STEPS defaults to 16 (total 80)**. Only simple scenes (single cube/sphere) can increase to 96+24. Multi-building/Minecraft/character scenes with complex getVoxel must keep 64+16 or lower, otherwise SwiftShader frame timeout → only sky background renders + +**Composition Tips:** +- **Procedural noise terrain**: use FBM/Perlin noise height maps inside `getVoxel()` +- **SDF procedural modeling**: use SDF boolean operations inside `getVoxel()` to define shapes +- **Texture mapping**: after hit, sample 16x16 pixel textures using face UV * 16 +- **Atmospheric scattering / volumetric fog**: accumulate medium density during DDA traversal +- **Water surface rendering**: Fresnel reflection/refraction on a specific Y plane (see Variant 6 above) +- **Global illumination**: cone tracing or Monte Carlo hemisphere sampling +- **Temporal reprojection**: multi-frame accumulation + previous frame reprojection for anti-aliasing and denoising + +## Common Errors + +1. **GLSL reserved words causing compile failure**: `cast`, `class`, `template`, `namespace`, `input`, `output`, `filter`, `image`, `sampler`, `half`, `fixed`, etc. are GLSL reserved words and **must never be used as variable or function names**. Use compound names: `castRay`, `castShadow`, `shootRay`, `spellEffect` (not `cast`) +2. **Enclosed/semi-enclosed scene total darkness**: caves, interiors, sci-fi bases, mazes, and other enclosed scenes cannot rely solely on directional light (completely blocked by walls/ceiling); must use point lights + high ambient light (≥0.2) + emissive materials (see Variant 8) +3. **Camera inside voxel causing rendering anomalies**: cave/indoor scene camera origin must be inside the cavity (where getVoxel returns 0), otherwise the first DDA step hits immediately = scene invisible +4. **Complex getVoxel causing SwiftShader black screen (most common with Minecraft-style/town/character/multi-building scenes!)**: getVoxel is called once per DDA step; if it contains multiple buildings/characters/terrain+trees without early exit, frame timeout → only sky background renders. **Must do all of**: (1) AABB bounding box early exit (check coordinate range first, return 0 immediately outside building area); (2) MAX_RAY_STEPS ≤ 64, MAX_SHADOW_STEPS ≤ 16; (3) scene range within ±20 cells. **Minecraft-style scene = multi-building scene**; must follow this rule (see Variant 9, 11 template code) +5. **vec2/vec3 dimension mismatch causing compile failure**: `p.xz` returns `vec2` and cannot be passed directly to noise/fbm functions expecting `vec3` parameters or used in operations with `vec3`. Use `vec3(p.xz, val)` to construct a full vec3, or use vec2 versions of functions +6. **Mountain/terrain height-based coloring invisible**: (1) `maxH` must equal the actual max return value of the terrain noise function (don't arbitrarily use 20.0); (2) grass threshold at 0.4 (largest area ensures green is visible), rock 0.4~0.7, snow >0.7; (3) grass green must be saturated enough `vec3(0.25, 0.55, 0.15)` not grayish; (4) sun intensity ≤2.0, sky light ≤1.0, too bright washes out colors; (5) gamma correction reduces saturation, pre-compensate material colors (see Step 4 mountain terrain template) +7. **Waterfall/flowing water effect lacks recognizability**: waterfall must have a clear cliff drop (≥10 cells), visible water column (noise + iTime offset), bottom splash particles (hash random bouncing), and mist (exponential decay density field). Just a gradient color block is not a waterfall! See Variant 10 complete template +8. **"Low saturation coloring" becomes pure white/gray**: low saturation ≠ near white! Low saturation means colors are not vivid but still have clear hue (e.g., brick red `vec3(0.55, 0.35, 0.3)` not gray-white `vec3(0.8, 0.8, 0.8)`). Brick/stone textures must use UV periodic patterns (staggered rows + mortar dark lines), not solid colors. See the `getBrickColor` function in the complete template +9. **Sunset/side-lit scene buildings become black silhouettes**: when low-angle light (sunset/dawn) illuminates from the side, building fronts are completely backlit → pure black silhouettes with no visible detail. Must: (1) ambient light ≥ 0.3; (2) walls use bright materials (light yellow, off-white) not dark colors; (3) buildings large enough (width/height ≥ 5 cells). See Variant 11 sunset scene code + +## Further Reading + +For full step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/voxel-rendering.md) diff --git a/skills/shader-dev/techniques/water-ocean.md b/skills/shader-dev/techniques/water-ocean.md new file mode 100644 index 0000000..e534f9c --- /dev/null +++ b/skills/shader-dev/techniques/water-ocean.md @@ -0,0 +1,490 @@ +# Water & Ocean Rendering Skill + +## Use Cases +- Rendering water body surfaces such as oceans, lakes, and rivers +- Water surface reflection/refraction, Fresnel effects +- Underwater caustics lighting effects +- Waves, foam, and water flow animation + +## Core Principles + +Water rendering solves three problems: **water surface shape generation**, **light-water surface interaction**, and **water body color compositing**. + +### Wave Generation: Exponential Sine Stacking + Derivative Domain Warping + +`wave(x) = exp(sin(x) - 1)` — sharp wave crests (`exp(0)=1`), broad flat troughs (`exp(-2)≈0.135`), similar to a trochoidal profile but at much lower computational cost than Gerstner waves. + +When stacking multiple waves, use **derivative domain warping (Drag)**: +``` +position += direction * derivative * weight * DRAG_MULT +``` +Small ripples cluster on the crests of large waves, simulating capillary waves riding on gravity waves. + +### Lighting: Schlick Fresnel + Subsurface Scattering + +- **Schlick Fresnel**: `F = F0 + (1-F0) * (1-dot(N,V))^5`, water F0 ≈ 0.04 +- **SSS approximation**: thicker water layer at troughs → stronger blue-green scattering; thinner layer at crests → weaker scattering + +### Water Surface Intersection: Bounded Height Field Marching + +The water surface is constrained within a `[0, -WATER_DEPTH]` bounding box, with adaptive step size: `step = ray_y - wave_height`. + +## Implementation Steps + +### Step 1: Exponential Sine Wave Function +```glsl +// Single wave: exp(sin(x)-1) produces sharp peaks and broad troughs, returns (value, negative derivative) +vec2 wavedx(vec2 position, vec2 direction, float frequency, float timeshift) { + float x = dot(direction, position) * frequency + timeshift; + float wave = exp(sin(x) - 1.0); + float dx = wave * cos(x); + return vec2(wave, -dx); +} +``` + +### Step 2: Multi-Octave Wave Stacking with Domain Warping +```glsl +#define DRAG_MULT 0.38 // Domain warp strength, 0=none, 0.5=strong clustering + +float getwaves(vec2 position, int iterations) { + float wavePhaseShift = length(position) * 0.1; + float iter = 0.0; + float frequency = 1.0; + float timeMultiplier = 2.0; + float weight = 1.0; + float sumOfValues = 0.0; + float sumOfWeights = 0.0; + for (int i = 0; i < iterations; i++) { + vec2 p = vec2(sin(iter), cos(iter)); // Pseudo-random wave direction + vec2 res = wavedx(position, p, frequency, iTime * timeMultiplier + wavePhaseShift); + position += p * res.y * weight * DRAG_MULT; // Derivative domain warp + sumOfValues += res.x * weight; + sumOfWeights += weight; + weight = mix(weight, 0.0, 0.2); // Weight decay + frequency *= 1.18; // Frequency growth rate + timeMultiplier *= 1.07; // Dispersion + iter += 1232.399963; // Uniform direction distribution + } + return sumOfValues / sumOfWeights; +} +``` + +### Step 3: Bounded Bounding Box Ray Marching +```glsl +#define WATER_DEPTH 1.0 + +float intersectPlane(vec3 origin, vec3 direction, vec3 point, vec3 normal) { + return clamp(dot(point - origin, normal) / dot(direction, normal), -1.0, 9991999.0); +} + +float raymarchwater(vec3 camera, vec3 start, vec3 end, float depth) { + vec3 pos = start; + vec3 dir = normalize(end - start); + for (int i = 0; i < 64; i++) { + float height = getwaves(pos.xz, ITERATIONS_RAYMARCH) * depth - depth; + if (height + 0.01 > pos.y) { + return distance(pos, camera); + } + pos += dir * (pos.y - height); // Adaptive step size + } + return distance(start, camera); +} +``` + +### Step 4: Normal Calculation and Distance Smoothing +```glsl +#define ITERATIONS_RAYMARCH 12 // For marching (fewer = faster) +#define ITERATIONS_NORMAL 36 // For normals (more = finer detail) + +vec3 calcNormal(vec2 pos, float e, float depth) { + vec2 ex = vec2(e, 0); + float H = getwaves(pos.xy, ITERATIONS_NORMAL) * depth; + vec3 a = vec3(pos.x, H, pos.y); + return normalize( + cross( + a - vec3(pos.x - e, getwaves(pos.xy - ex.xy, ITERATIONS_NORMAL) * depth, pos.y), + a - vec3(pos.x, getwaves(pos.xy + ex.yx, ITERATIONS_NORMAL) * depth, pos.y + e) + ) + ); +} + +// Distance smoothing: normals approach (0,1,0) at far distances +// N = mix(N, vec3(0.0, 1.0, 0.0), 0.8 * min(1.0, sqrt(dist * 0.01) * 1.1)); +``` + +### Step 5: Fresnel Reflection and Subsurface Scattering +```glsl +float fresnel = 0.04 + 0.96 * pow(1.0 - max(0.0, dot(-N, ray)), 5.0); + +vec3 R = normalize(reflect(ray, N)); +R.y = abs(R.y); // Force upward to avoid self-intersection + +vec3 reflection = getAtmosphere(R) + getSun(R); + +vec3 scattering = vec3(0.0293, 0.0698, 0.1717) * 0.1 + * (0.2 + (waterHitPos.y + WATER_DEPTH) / WATER_DEPTH); + +vec3 C = fresnel * reflection + scattering; +``` + +### Step 6: Atmosphere and Tone Mapping +```glsl +vec3 extra_cheap_atmosphere(vec3 raydir, vec3 sundir) { + float special_trick = 1.0 / (raydir.y * 1.0 + 0.1); + float special_trick2 = 1.0 / (sundir.y * 11.0 + 1.0); + float raysundt = pow(abs(dot(sundir, raydir)), 2.0); + float sundt = pow(max(0.0, dot(sundir, raydir)), 8.0); + float mymie = sundt * special_trick * 0.2; + vec3 suncolor = mix(vec3(1.0), max(vec3(0.0), vec3(1.0) - vec3(5.5, 13.0, 22.4) / 22.4), + special_trick2); + vec3 bluesky = vec3(5.5, 13.0, 22.4) / 22.4 * suncolor; + vec3 bluesky2 = max(vec3(0.0), bluesky - vec3(5.5, 13.0, 22.4) * 0.002 + * (special_trick + -6.0 * sundir.y * sundir.y)); + bluesky2 *= special_trick * (0.24 + raysundt * 0.24); + return bluesky2 * (1.0 + 1.0 * pow(1.0 - raydir.y, 3.0)); +} + +vec3 aces_tonemap(vec3 color) { + mat3 m1 = mat3( + 0.59719, 0.07600, 0.02840, + 0.35458, 0.90834, 0.13383, + 0.04823, 0.01566, 0.83777); + mat3 m2 = mat3( + 1.60475, -0.10208, -0.00327, + -0.53108, 1.10813, -0.07276, + -0.07367, -0.00605, 1.07602); + vec3 v = m1 * color; + vec3 a = v * (v + 0.0245786) - 0.000090537; + vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081; + return pow(clamp(m2 * (a / b), 0.0, 1.0), vec3(1.0 / 2.2)); +} +``` + +## Complete Code Template + +Can be pasted directly into ShaderToy to run. Distilled from `afl_ext`'s "Very fast procedural ocean". + +```glsl +// Water & Ocean Rendering — ShaderToy Template +// exp(sin) wave model + derivative domain warp + Schlick Fresnel + SSS + +// ==================== Tunable Parameters ==================== +#define DRAG_MULT 0.38 +#define WATER_DEPTH 1.0 +#define CAMERA_HEIGHT 1.5 +#define ITERATIONS_RAYMARCH 12 +#define ITERATIONS_NORMAL 36 +#define RAYMARCH_STEPS 64 +#define NORMAL_EPSILON 0.01 +#define FRESNEL_F0 0.04 +#define SSS_COLOR vec3(0.0293, 0.0698, 0.1717) +#define SSS_INTENSITY 0.1 +#define SUN_POWER 720.0 +#define SUN_BRIGHTNESS 210.0 +#define EXPOSURE 2.0 + +// ==================== Wave Functions ==================== +vec2 wavedx(vec2 position, vec2 direction, float frequency, float timeshift) { + float x = dot(direction, position) * frequency + timeshift; + float wave = exp(sin(x) - 1.0); + float dx = wave * cos(x); + return vec2(wave, -dx); +} + +float getwaves(vec2 position, int iterations) { + float wavePhaseShift = length(position) * 0.1; + float iter = 0.0; + float frequency = 1.0; + float timeMultiplier = 2.0; + float weight = 1.0; + float sumOfValues = 0.0; + float sumOfWeights = 0.0; + for (int i = 0; i < iterations; i++) { + vec2 p = vec2(sin(iter), cos(iter)); + vec2 res = wavedx(position, p, frequency, iTime * timeMultiplier + wavePhaseShift); + position += p * res.y * weight * DRAG_MULT; + sumOfValues += res.x * weight; + sumOfWeights += weight; + weight = mix(weight, 0.0, 0.2); + frequency *= 1.18; + timeMultiplier *= 1.07; + iter += 1232.399963; + } + return sumOfValues / sumOfWeights; +} + +// ==================== Ray Marching ==================== +float intersectPlane(vec3 origin, vec3 direction, vec3 point, vec3 normal) { + return clamp(dot(point - origin, normal) / dot(direction, normal), -1.0, 9991999.0); +} + +float raymarchwater(vec3 camera, vec3 start, vec3 end, float depth) { + vec3 pos = start; + vec3 dir = normalize(end - start); + for (int i = 0; i < RAYMARCH_STEPS; i++) { + float height = getwaves(pos.xz, ITERATIONS_RAYMARCH) * depth - depth; + if (height + 0.01 > pos.y) { + return distance(pos, camera); + } + pos += dir * (pos.y - height); + } + return distance(start, camera); +} + +// ==================== Normals ==================== +vec3 calcNormal(vec2 pos, float e, float depth) { + vec2 ex = vec2(e, 0); + float H = getwaves(pos.xy, ITERATIONS_NORMAL) * depth; + vec3 a = vec3(pos.x, H, pos.y); + return normalize( + cross( + a - vec3(pos.x - e, getwaves(pos.xy - ex.xy, ITERATIONS_NORMAL) * depth, pos.y), + a - vec3(pos.x, getwaves(pos.xy + ex.yx, ITERATIONS_NORMAL) * depth, pos.y + e) + ) + ); +} + +// ==================== Camera ==================== +#define NormalizedMouse (iMouse.xy / iResolution.xy) + +mat3 createRotationMatrixAxisAngle(vec3 axis, float angle) { + float s = sin(angle); + float c = cos(angle); + float oc = 1.0 - c; + return mat3( + oc * axis.x * axis.x + c, oc * axis.x * axis.y - axis.z * s, oc * axis.z * axis.x + axis.y * s, + oc * axis.x * axis.y + axis.z * s, oc * axis.y * axis.y + c, oc * axis.y * axis.z - axis.x * s, + oc * axis.z * axis.x - axis.y * s, oc * axis.y * axis.z + axis.x * s, oc * axis.z * axis.z + c + ); +} + +vec3 getRay(vec2 fragCoord) { + vec2 uv = ((fragCoord.xy / iResolution.xy) * 2.0 - 1.0) * vec2(iResolution.x / iResolution.y, 1.0); + vec3 proj = normalize(vec3(uv.x, uv.y, 1.5)); + if (iResolution.x < 600.0) return proj; + return createRotationMatrixAxisAngle(vec3(0.0, -1.0, 0.0), 3.0 * ((NormalizedMouse.x + 0.5) * 2.0 - 1.0)) + * createRotationMatrixAxisAngle(vec3(1.0, 0.0, 0.0), 0.5 + 1.5 * (((NormalizedMouse.y == 0.0 ? 0.27 : NormalizedMouse.y)) * 2.0 - 1.0)) + * proj; +} + +// ==================== Atmosphere ==================== +vec3 getSunDirection() { + return normalize(vec3(-0.0773502691896258, 0.5 + sin(iTime * 0.2 + 2.6) * 0.45, 0.5773502691896258)); +} + +vec3 extra_cheap_atmosphere(vec3 raydir, vec3 sundir) { + float special_trick = 1.0 / (raydir.y * 1.0 + 0.1); + float special_trick2 = 1.0 / (sundir.y * 11.0 + 1.0); + float raysundt = pow(abs(dot(sundir, raydir)), 2.0); + float sundt = pow(max(0.0, dot(sundir, raydir)), 8.0); + float mymie = sundt * special_trick * 0.2; + vec3 suncolor = mix(vec3(1.0), max(vec3(0.0), vec3(1.0) - vec3(5.5, 13.0, 22.4) / 22.4), special_trick2); + vec3 bluesky = vec3(5.5, 13.0, 22.4) / 22.4 * suncolor; + vec3 bluesky2 = max(vec3(0.0), bluesky - vec3(5.5, 13.0, 22.4) * 0.002 * (special_trick + -6.0 * sundir.y * sundir.y)); + bluesky2 *= special_trick * (0.24 + raysundt * 0.24); + return bluesky2 * (1.0 + 1.0 * pow(1.0 - raydir.y, 3.0)); +} + +vec3 getAtmosphere(vec3 dir) { + return extra_cheap_atmosphere(dir, getSunDirection()) * 0.5; +} + +float getSun(vec3 dir) { + return pow(max(0.0, dot(dir, getSunDirection())), SUN_POWER) * SUN_BRIGHTNESS; +} + +// ==================== Tone Mapping ==================== +vec3 aces_tonemap(vec3 color) { + mat3 m1 = mat3( + 0.59719, 0.07600, 0.02840, + 0.35458, 0.90834, 0.13383, + 0.04823, 0.01566, 0.83777); + mat3 m2 = mat3( + 1.60475, -0.10208, -0.00327, + -0.53108, 1.10813, -0.07276, + -0.07367, -0.00605, 1.07602); + vec3 v = m1 * color; + vec3 a = v * (v + 0.0245786) - 0.000090537; + vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081; + return pow(clamp(m2 * (a / b), 0.0, 1.0), vec3(1.0 / 2.2)); +} + +// ==================== Main Function ==================== +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec3 ray = getRay(fragCoord); + if (ray.y >= 0.0) { + vec3 C = getAtmosphere(ray) + getSun(ray); + fragColor = vec4(aces_tonemap(C * EXPOSURE), 1.0); + return; + } + + vec3 waterPlaneHigh = vec3(0.0, 0.0, 0.0); + vec3 waterPlaneLow = vec3(0.0, -WATER_DEPTH, 0.0); + vec3 origin = vec3(iTime * 0.2, CAMERA_HEIGHT, 1.0); + + float highPlaneHit = intersectPlane(origin, ray, waterPlaneHigh, vec3(0.0, 1.0, 0.0)); + float lowPlaneHit = intersectPlane(origin, ray, waterPlaneLow, vec3(0.0, 1.0, 0.0)); + vec3 highHitPos = origin + ray * highPlaneHit; + vec3 lowHitPos = origin + ray * lowPlaneHit; + + float dist = raymarchwater(origin, highHitPos, lowHitPos, WATER_DEPTH); + vec3 waterHitPos = origin + ray * dist; + + vec3 N = calcNormal(waterHitPos.xz, NORMAL_EPSILON, WATER_DEPTH); + N = mix(N, vec3(0.0, 1.0, 0.0), 0.8 * min(1.0, sqrt(dist * 0.01) * 1.1)); + + float fresnel = FRESNEL_F0 + (1.0 - FRESNEL_F0) * pow(1.0 - max(0.0, dot(-N, ray)), 5.0); + + vec3 R = normalize(reflect(ray, N)); + R.y = abs(R.y); + vec3 reflection = getAtmosphere(R) + getSun(R); + + vec3 scattering = SSS_COLOR * SSS_INTENSITY + * (0.2 + (waterHitPos.y + WATER_DEPTH) / WATER_DEPTH); + + vec3 C = fresnel * reflection + scattering; + fragColor = vec4(aces_tonemap(C * EXPOSURE), 1.0); +} +``` + +## Common Variants + +### Variant 1: 2D Underwater Caustic Texture +```glsl +#define TAU 6.28318530718 +#define MAX_ITER 5 + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + float time = iTime * 0.5 + 23.0; + vec2 uv = fragCoord.xy / iResolution.xy; + vec2 p = mod(uv * TAU, TAU) - 250.0; + vec2 i = vec2(p); + float c = 1.0; + float inten = 0.005; + + for (int n = 0; n < MAX_ITER; n++) { + float t = time * (1.0 - (3.5 / float(n + 1))); + i = p + vec2(cos(t - i.x) + sin(t + i.y), sin(t - i.y) + cos(t + i.x)); + c += 1.0 / length(vec2(p.x / (sin(i.x + t) / inten), p.y / (cos(i.y + t) / inten))); + } + c /= float(MAX_ITER); + c = 1.17 - pow(c, 1.4); + vec3 colour = vec3(pow(abs(c), 8.0)); + colour = clamp(colour + vec3(0.0, 0.35, 0.5), 0.0, 1.0); + fragColor = vec4(colour, 1.0); +} +``` + +### Variant 2: FBM Bump-Mapped Lake Surface +```glsl +float waterMap(vec2 pos) { + mat2 m2 = mat2(0.60, -0.80, 0.80, 0.60); + vec2 posm = pos * m2; + return abs(fbm(vec3(8.0 * posm, iTime)) - 0.5) * 0.1; +} + +// Analytic plane intersection instead of ray marching +float t = -ro.y / rd.y; +vec3 hitPos = ro + rd * t; + +// Finite difference normals (central differencing) +float eps = 0.1; +vec3 normal = vec3(0.0, 1.0, 0.0); +normal.x = -bumpfactor * (waterMap(hitPos.xz + vec2(eps, 0.0)) - waterMap(hitPos.xz - vec2(eps, 0.0))) / (2.0 * eps); +normal.z = -bumpfactor * (waterMap(hitPos.xz + vec2(0.0, eps)) - waterMap(hitPos.xz - vec2(0.0, eps))) / (2.0 * eps); +normal = normalize(normal); + +float bumpfactor = 0.1 * (1.0 - smoothstep(0.0, 60.0, distance(ro, hitPos))); +vec3 refracted = refract(rd, normal, 1.0 / 1.333); +``` + +### Variant 3: Ridge Noise Coastal Waves +```glsl +float sea(vec2 p) { + float f = 1.0; + float r = 0.0; + float time = -iTime; + for (int i = 0; i < 8; i++) { + r += (1.0 - abs(noise(p * f + 0.9 * time))) / f; + f *= 2.0; + p -= vec2(-0.01, 0.04) * (r - 0.2 * time / (0.1 - f)); + } + return r / 4.0 + 0.5; +} + +// Shoreline foam +float dh = seaDist - rockDist; +float foam = 0.0; +if (dh < 0.0 && dh > -0.02) { + foam = 0.5 * exp(20.0 * dh); +} +``` + +### Variant 4: Flow Map Water Animation +```glsl +vec3 FBM_DXY(vec2 p, vec2 flow, float persistence, float domainWarp) { + vec3 f = vec3(0.0); + float tot = 0.0; + float a = 1.0; + for (int i = 0; i < 4; i++) { + p += flow; + flow *= -0.75; + vec3 v = SmoothNoise_DXY(p); + f += v * a; + p += v.xy * domainWarp; + p *= 2.0; + tot += a; + a *= persistence; + } + return f / tot; +} + +// Two-phase flow cycle (eliminates stretching) +float t0 = fract(time); +float t1 = fract(time + 0.5); +vec4 sample0 = SampleWaterNormal(uv + Hash2(floor(time)), flowRate * (t0 - 0.5)); +vec4 sample1 = SampleWaterNormal(uv + Hash2(floor(time+0.5)), flowRate * (t1 - 0.5)); +float weight = abs(t0 - 0.5) * 2.0; +vec4 result = mix(sample0, sample1, weight); +``` + +### Variant 5: Beer's Law Water Absorption +```glsl +vec3 GetWaterExtinction(float dist) { + float fOpticalDepth = dist * 6.0; + vec3 vExtinctCol = vec3(0.5, 0.6, 0.9); + return exp2(-fOpticalDepth * vExtinctCol); +} + +vec3 vInscatter = vSurfaceDiffuse * (1.0 - exp(-refractDist * 0.1)) + * (1.0 + dot(sunDir, viewDir)); + +vec3 underwaterColor = terrainColor * GetWaterExtinction(waterDepth) + vInscatter; +vec3 finalColor = mix(underwaterColor, reflectionColor, fresnel); +``` + +## Performance & Composition + +### Performance Tips +- **Dual iteration count strategy**: 12 iterations for marching, 36 for normals — halves render time with virtually no visual loss +- **Distance-adaptive normal smoothing**: `N = mix(N, up, 0.8 * min(1.0, sqrt(dist*0.01)*1.1))`, eliminates distant flickering +- **Bounding box clipping**: pre-compute upper/lower plane intersections, early-out for sky directions +- **Adaptive step size**: `pos += dir * (pos.y - height)`, 3-5x faster than fixed steps +- **Filter-width-aware decay**: `dFdx/dFdy` driven normal LOD +- **LOD conditional detail**: only compute high-frequency displacement at close range + +### Composition Tips +- **Volumetric clouds**: ray march clouds along reflection direction `R`, blend into reflection term +- **Terrain coastline**: `dh = waterSDF - terrainSDF`, render foam when `dh ≈ 0` +- **Caustics overlay**: project Variant 1 onto underwater terrain, `caustic * exp(-depth * absorption)` depth attenuation +- **Fog/atmosphere**: independent extinction + in-scatter, per-channel RGB decay: + ```glsl + vec3 fogExtinction = exp2(fogExtCoeffs * -distance); + vec3 fogInscatter = fogColor * (1.0 - exp2(fogInCoeffs * -distance)); + finalColor = finalColor * fogExtinction + fogInscatter; + ``` +- **Post-processing**: Bloom (Fibonacci spiral blur), ACES tone mapping, depth of field (DOF) + +## Further Reading + +For full step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/water-ocean.md) diff --git a/skills/shader-dev/techniques/webgl-pitfalls.md b/skills/shader-dev/techniques/webgl-pitfalls.md new file mode 100644 index 0000000..773492f --- /dev/null +++ b/skills/shader-dev/techniques/webgl-pitfalls.md @@ -0,0 +1,170 @@ +# WebGL2 Pitfalls & Common Errors + +## Use Cases + +- Avoiding common GLSL compilation errors when generating standalone WebGL2 shader pages +- Debugging shader compilation failures +- Ensuring shader templates from ShaderToy work correctly in WebGL2 + +## Critical WebGL2 Rules + +### 1. Fragment Coordinate — Use `gl_FragCoord.xy` + +**ERROR**: `'fragCoord' : undeclared identifier` + +In WebGL2 fragment shaders, `fragCoord` is not a built-in variable. Use `gl_FragCoord.xy` instead. + +```glsl +// WRONG +void main() { + vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; +} + +// CORRECT +void main() { + vec2 uv = (2.0 * gl_FragCoord.xy - iResolution.xy) / iResolution.y; +} +``` + +### 2. Shadertoy mainImage — Must Wrap in `main()` + +**ERROR**: `'' : Missing main()` + +If your fragment shader uses `void mainImage(out vec4, in vec2)`, you must provide a `main()` wrapper. + +```glsl +// WRONG — only defines mainImage but no main() +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + // shader code... + fragColor = vec4(col, 1.0); +} + +// CORRECT +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + // shader code... + fragColor = vec4(col, 1.0); +} + +void main() { + mainImage(fragColor, gl_FragCoord.xy); +} +``` + +### 3. Function Declaration Order — Declare Before Use + +**ERROR**: `'functionName' : no matching overloaded function found` + +GLSL requires functions to be declared before they are used. Forward declarations or reordering is needed. + +```glsl +// WRONG — getAtmosphere() calls getSunDirection() which is defined after +vec3 getAtmosphere(vec3 dir) { + return extra_cheap_atmosphere(dir, getSunDirection()) * 0.5; // Error! +} +vec3 getSunDirection() { + return normalize(vec3(-0.5, 0.8, -0.6)); +} + +// CORRECT — reorder functions +vec3 getSunDirection() { // Define first + return normalize(vec3(-0.5, 0.8, -0.6)); +} +vec3 getAtmosphere(vec3 dir) { // Now can call getSunDirection() + return extra_cheap_atmosphere(dir, getSunDirection()) * 0.5; +} +``` + +### 4. Macro Limitations — `#define` Cannot Use Functions + +**ERROR**: Various compilation errors with `#define` macros + +Macros are text substitution and cannot call functions or use parentheses in the same way as C++. + +```glsl +// WRONG +#define SUN_DIR normalize(vec3(0.8, 0.4, -0.6)) +#define WORLD_TIME (iTime * speed()) + +// CORRECT — use const +const vec3 SUN_DIR = vec3(0.756, 0.378, -0.567); // Pre-computed normalized value +const float WORLD_TIME = 1.0; +``` + +### 5. Vector Component Access — Terrain Functions + +**ERROR**: `'terrainM' : no matching overloaded function found` + +When passing positions to terrain functions that expect `vec2`, extract the XZ components properly. + +```glsl +// WRONG — terrainM expects vec2, but passing vec3 +float calcAO(vec3 pos, vec3 nor) { + float d = terrainM(pos + h * nor); // Error: pos + h*nor is vec3 + ... +} + +// CORRECT — extract xz components +float calcAO(vec3 pos, vec3 nor) { + float d = terrainM(pos.xz + h * nor.xz); + ... +} +``` + +### 6. Loop Index — Use Runtime Constants + +**ERROR**: Loop index must be a runtime expression + +GLSL ES requires loop indices to be determinable at runtime, not compile-time constants in some contexts. + +```glsl +// WRONG — AA is a #define constant +for (int i = 0; i < AA; i++) { ... } + +// CORRECT — use a runtime-safe approach +for (int i = 0; i < 4; i++) { ... } // Or pass as uniform +``` + +### 7. Uniform Usage — Avoid Unused Uniforms + +**ERROR**: Uniform optimized away causes `gl.getUniformLocation()` to return `null` + +If a uniform is declared but not used, the compiler may optimize it out. + +```glsl +// WRONG — iTime declared but used in a conditional that might be false +uniform float iTime; +if (false) { x = iTime; } // iTime optimized away + +// CORRECT — always use the uniform in a way the compiler can't optimize out +uniform float iTime; +float t = iTime * 0.0; // Always use iTime somehow +if (someCondition) { x = t; } +``` + +## Complete WebGL2 Adaptation Checklist + +When generating standalone HTML pages: + +1. **Shader Version**: `#version 300 es` must be the very first line +2. **Fragment Output**: Declare `out vec4 fragColor;` +3. **Entry Point**: Wrap `mainImage()` in `void main()` that calls `mainImage(fragColor, gl_FragCoord.xy)` +4. **Fragment Coord**: Use `gl_FragCoord.xy` not `fragCoord` +5. **Preprocessor**: Don't use functions in `#define` macros +6. **Function Order**: Declare functions before they are used, or use forward declarations +7. **Texture**: Use `texture()` not `texture2D()` +8. **Attributes**: `attribute` → `in`, `varying` → `in`/`out` + +## Common Error Messages Reference + +| Error Message | Likely Cause | Solution | +|---|---|---| +| `'fragCoord' : undeclared identifier` | Using `fragCoord` instead of `gl_FragCoord.xy` | Replace with `gl_FragCoord.xy` | +| `'' : Missing main()` | No `main()` function defined | Add wrapper `void main() { mainImage(...); }` | +| `'function' : no matching overloaded function` | Wrong argument types or function order | Check parameter types, reorder functions | +| `return' : function return is not matching` | Return type mismatch | Verify return expression matches declared return type | +| `#version` must be first | Leading whitespace in shader source | Use `.trim()` when extracting from script tags | +| Uniform `null` from `getUniformLocation` | Uniform optimized away | Ensure uniform is actually used in shader code | + +## Further Reading + +See [reference/webgl-pitfalls.md](../reference/webgl-pitfalls.md) for additional debugging techniques.