1. Trang chủ
  2. » Công Nghệ Thông Tin

Metal by Tutorials beginning game engine development with metal by raywenderlich tutorial team, caroline begbie, marius horga

722 45 0

Đang tải... (xem toàn văn)

Tài liệu hạn chế xem trước, để xem đầy đủ mời bạn chọn Tải xuống

THÔNG TIN TÀI LIỆU

Thông tin cơ bản

Định dạng
Số trang 722
Dung lượng 10,36 MB

Nội dung

Metal by tutorials beginning game engine development with metal by raywenderlich tutorial team, caroline begbie, marius horgaThis book will introduce you to graphics programming in Metal — Apple’s framework for programming on the GPU. Build a complete game engine in Metal

Metal by Tutorials Metal by Tutorials By Caroline Begbie & Marius Horga Copyright ©2019 Razeware LLC Notice of Rights All rights reserved No part of this book or corresponding materials (such as text, images, or source code) may be reproduced or distributed by any means without prior written permission of the copyright owner Notice of Liability This book and all corresponding materials (such as source code) are provided on an “as is” basis, without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in action of contract, tort or otherwise, arising from, out of or in connection with the software or the use of other dealing in the software Trademarks All trademarks and registered trademarks appearing in this book are the property of their own respective owners raywenderlich.com Metal by Tutorials Table of Contents: Overview About the Cover 13 What You Need 17 Book License 18 Book Source Code & Forums 19 Book Updates 21 Introduction 22 Section I: The Player 26 Chapter 1: Hello, Metal! 28 Chapter 2: 3D Models 42 Chapter 3: The Rendering Pipeline 64 Chapter 4: Coordinate Spaces 89 Chapter 5: Lighting Fundamentals 121 Chapter 6: Textures 154 Chapter 7: Maps & Materials 185 Chapter 8: Character Animation 216 Section II: The Scene 254 Chapter 9: The Scene Graph 256 Chapter 10: Fragment Post-Processing 289 Chapter 11: Tessellation & Terrains 313 Chapter 12: Environment 343 Chapter 13: Instancing & Procedural Generation 373 raywenderlich.com Metal by Tutorials Chapter 14: Multipass & Deferred Rendering 406 Chapter 15: GPU-Driven Rendering 434 Section III: The Effects 470 Chapter 16: Particle Systems 472 Chapter 17: Particle Behavior 499 Chapter 18: Rendering with Rays 517 Chapter 19: Advanced Shadows 543 Chapter 20: Advanced Lighting 572 Chapter 21: Metal Performance Shaders 605 Chapter 22: Integrating SpriteKit & SceneKit 640 Chapter 23: Debugging & Profiling 671 Chapter 24: Performance Optimization 698 Conclusion 722 raywenderlich.com Metal by Tutorials Table of Contents: Extended About the Cover 13 About the Authors 15 About the Editors 15 About the Artist 15 What You Need 17 Book License 18 Book Source Code & Forums 19 Book Updates 21 Introduction 22 About this book How did Metal come to life? Why would you use Metal? When should you use Metal? Who this book is for How to read this book 22 23 23 24 25 25 Section I: The Player 26 Chapter 1: Hello, Metal! 28 What is rendering? What is a frame? Your first Metal app The view Rendering Challenge 29 30 31 32 37 41 Chapter 2: 3D Models 42 What are 3D models? 43 Creating models with Blender 46 raywenderlich.com Metal by Tutorials 3D file formats Exporting to Blender The obj file format The mtl file format Material groups Vertex descriptors Metal coordinate system Submeshes Challenge 47 48 50 51 54 56 59 60 63 Chapter 3: The Rendering Pipeline 64 The GPU and the CPU The Metal project The rendering pipeline Send data to the GPU Challenge 65 66 74 86 88 Chapter 4: Coordinate Spaces 89 Transformations 90 Translation 90 Vectors and matrices 95 Matrices on the GPU 98 Scaling 99 Rotation 101 Coordinate spaces 104 Upgrade the engine 109 Uniforms 109 Projection 115 Where to go from here? 120 Chapter 5: Lighting Fundamentals 121 The starter project 122 Representing color 124 raywenderlich.com Metal by Tutorials Normals Depth Hemispheric lighting Light types Directional light The Phong reflection model The dot product Point lights Spotlights Challenge Where to go from here? 125 128 131 132 133 135 136 146 150 152 153 Chapter 6: Textures 154 Textures and UV maps Texture the model sRGB color space GPU frame capture Samplers Mipmaps The asset catalog Texture compression Where to go from here? 155 158 165 167 169 175 178 182 184 Chapter 7: Maps & Materials 185 Normal maps Materials Function specialization Physically based rendering Channel packing Challenge Where to go from here? 186 201 204 208 212 214 214 Chapter 8: Character Animation 216 raywenderlich.com Metal by Tutorials The starter project Procedural animation Animation using physics Keyframes Quaternions USD and USDZ files Animating meshes Blender for animating Skeletal Animation Loading the animation Joint matrix palette The inverse bind matrix Vertex function constants Where to go from here? 217 218 218 221 227 230 231 235 241 244 245 247 250 253 Section II: The Scene 254 Chapter 9: The Scene Graph 256 Scenes The scene graph Grouping nodes First-person camera Orthographic projection Third-person camera Animating the player Simple collisions Where to go from here? 257 259 266 269 276 279 281 282 288 Chapter 10: Fragment Post-Processing 289 Getting started Alpha testing Depth testing Stencil testing raywenderlich.com 290 291 296 296 Metal by Tutorials Scissor testing Alpha blending Antialiasing Fog Challenge Where to go from here? 297 298 306 308 311 312 Chapter 11: Tessellation & Terrains 313 Tessellation The starter project Kernel (compute) functions Multiple patches Tessellation by distance Displacement Shading by slope Challenge Where to go from here? 314 315 319 326 328 332 338 340 341 Chapter 12: Environment 343 Getting started The skybox Procedural skies Reflection Image-based lighting Challenge Where to go from here? 344 345 351 357 360 372 372 Chapter 13: Instancing & Procedural Generation 373 The starter project Instancing Morphing Texture arrays Procedural systems raywenderlich.com 374 375 382 393 396 Metal by Tutorials Challenge 404 Where to go from here? 405 Chapter 14: Multipass & Deferred Rendering 406 Shadow maps Multipass rendering Deferred rendering Where to go from here? 407 411 417 433 Chapter 15: GPU-Driven Rendering 434 Argument buffers Resource heaps Indirect Command Buffers GPU driven rendering Where to go from here? 435 441 449 459 469 Section III: The Effects 470 Chapter 16: Particle Systems 472 Particle Emitter Compute Threads and threadgroups Fireworks Particle systems Fire Where to go from here? 473 474 476 478 483 488 495 497 Chapter 17: Particle Behavior 499 Behavioral animation Swarming behavior The project Velocity Behavioral rules raywenderlich.com 500 501 502 504 506 10 Metal by Tutorials Chapter 24: Performance Optimization iOSMac Families: Refers to Metal features for Mac Family GPUs running iPad apps ported to macOS via Catalyst • iOSMac - features from the Common family, plus BC Pixel Formats, Managed Textures, Cube Texture Arrays, Read/Write Textures Tier 1, Layered Rendering, Multiple Viewports/Scissors and Indirect Tessellation • iOSMac - features from the Common family, plus BC Pixel Formats and Managed Textures Note: A GPU can be a member of more than one family so it supports one of the Common families and one or more of the other families For a complete list of supported features, consult Apple’s webpage at https:// developer.apple.com/metal/Metal-Feature-Set-Tables.pdf You can test what GPU Families your devices have by using an #available clause Add this code at the end of init(metalView:) in Renderer.swift: let devices = MTLCopyAllDevices() for device in devices { if #available(macOS 10.15, *) { if device.supportsFamily(.mac2) { print("\(device.name) is a Mac family gpu running on macOS Catalina.") } else { print("\(device.name) is a Mac family gpu running on macOS Catalina.") } } else { if device.supportsFeatureSet(.macOS_GPUFamily2_v1) { print("You are using a recent GPU with an older version of macOS.") } else { print("You are using an older GPU with an older version of macOS.") } } } Build and run The output in the debug console will look something like this: AMD Radeon RX Vega 64 is a Mac family gpu running on macOS Catalina raywenderlich.com 708 Metal by Tutorials Chapter 24: Performance Optimization Intel(R) HD Graphics 530 is a Mac family gpu running on macOS Catalina AMD Radeon Pro 450 is a Mac family gpu running on macOS Catalina Memory management Whenever you create a buffer or a texture, you should consider how to configure it for fast memory access and driver performance optimizations Resource storage modes let you define the storage location and access permissions for your buffers and textures All iOS and tvOS devices support a unified memory model where both the CPU and the GPU share the system memory, while macOS devices support a discrete memory model where the GPU has its own memory In iOS and tvOS, the Shared mode (MTLStorageModeShared) defines system memory accessible to both CPU and GPU, while Private mode (MTLStorageModePrivate) defines system memory accessible only to the GPU The Shared mode is the default storage mode on all three operating systems macOS also has a Managed mode (MTLStorageModeManaged) that defines a synchronized memory pair for a resource, with one copy in system memory and another in video memory for faster CPU and GPU local accesses There are basically only four main rules to keep in mind, one for each of the storage modes: • Shared: Default on macOS buffers, iOS/tvOS resources; not available on macOS textures raywenderlich.com 709 Metal by Tutorials Chapter 24: Performance Optimization • Private: Mostly use when data is only accessed by GPU • Memoryless: Only for iOS/tvOS on-chip temporary render targets (textures) • Managed: Default mode for macOS textures; not available on iOS/tvOS resources For a better big picture, here is the full cheat sheet in case you might find it easier to use than remembering the rules above: The most complicated case is when working with macOS buffers and when the data needs to be accessed by both the CPU and the GPU You should choose the storage mode based on whether one or more of the following conditions are true: • Private: For large-sized data that changes at most once, so it is not “dirty” at all Create a source buffer with a Shared mode and then blit its data into a destination buffer with a Private mode Resource coherency is not necessary in this case as the data is only accessed by the GPU This operation is the least expensive (a one-time cost) • Managed: or medium-sized data that changes infrequently (every few frames), so it is partially “dirty” One copy of the data is stored in system memory for the CPU and another copy is stored in GPU memory Resource coherency is explicitly managed by synchronizing the two copies • Shared: For small-sized data that is updated every frame, so it is fully dirty Data resides in the system memory and is visible and modifiable by both the CPU and the GPU Resource coherency is only guaranteed within command buffer boundaries How you make sure coherency is guaranteed? First, make sure that all the modifications done by the CPU are finished before the command buffer is committed (check if the command buffer status property is MTLCommandBufferStatusCommitted) After the GPU finishes executing the command buffer, the CPU should only start making modifications again only after the GPU is signaling the CPU that the command buffer finished executing (check if the command buffer status property is MTLCommandBufferStatusCompleted) Finally, how is synchronization done for macOS resources? raywenderlich.com 710 Metal by Tutorials Chapter 24: Performance Optimization • For buffers: After a CPU write, use didModifyRange to inform the GPU of the changes so Metal can update that data region only; after a GPU write use synchronize(resource:) within a blit operation, to refresh the caches so the CPU can access the updated data • For textures: After a CPU write, use one of the two replace region functions to inform the GPU of the changes so Metal can update that data region only; after a GPU write use one of the two synchronize functions within a blit operation to allow Metal to update the system memory copy after the GPU finished modifying the data Now, look at what happens on the GPU when you send it data buffers Here is a typical vertex shader example: vertex Vertices vertex_func( const device Vertices *vertices [[buffer(0)]], constant Uniforms &uniforms [[buffer(1)]], uint vid [[vertex_id]]) {} The Metal Shading Language implements address space qualifiers to specify the region of memory where a function variable or argument is allocated: • device: Refers to buffer memory objects allocated from the device memory pool that are both readable and writeable unless the keyword const precedes it in which case the objects are only readable • constant: Refers to buffer memory objects allocated from the device memory pool but that are read-only Variables in program scope must be declared in the constant address space and initialized during the declaration statement The constant address space is optimized for multiple instances executing a graphics or kernel function accessing the same location in the buffer • threadgroup: Used to allocate variables used by kernel functions only and they are allocated for each threadgroup executing the kernel, are shared by all threads in a threadgroup and exist only for the lifetime of the threadgroup that is executing the kernel • thread: Refers to the per-thread memory address space Variables allocated in this address space are not visible to other threads Variables declared inside a graphics or kernel function are allocated in the thread address space Starting with macOS Catalina some Mac systems directly connect GPUs to each other (they are said to be in the same peer group), allowing you to quickly transfer data between them These connections are not only faster, but they also avoid using the memory bus between the CPU and GPUs, leaving it available for other tasks If your raywenderlich.com 711 Metal by Tutorials Chapter 24: Performance Optimization app uses multiple GPUs, test to see if they’re connected (if device.peerGroupID returns a non-zero value), and when they are, you can use a blit command encoder to transfer data You can read more on Apple’s webpage at https://developer.apple.com/ documentation/metal/transferring_data_between_connected_gpus Best practices When you are after squeezing the very last ounce of performance from your app, you should always remember to follow a golden set of best practices They are categorized into three major parts: General Performance, Memory Bandwidth and Memory Footprint General performance best practices The next five best practices are general and apply to the entire pipeline Choose the right resolution The game or app UI should be at native or close to native resolution so that the UI will always look crisp no matter the display size Also, it is recommended (albeit, not mandatory) that all resources have the same resolution You can check the resolutions in the GPU Debugger, on the Dependency Viewer Below is the multi-pass render from Chapter 14, "Multipass & Deferred Rendering": Notice here that the G-Buffer pass uses render targets that have a different resolution than the shadow and composition passes You should consider the performance trade-offs of each image resolution and carefully choose the scenario that best fits your app needs raywenderlich.com 712 Metal by Tutorials Chapter 24: Performance Optimization Minimize non-opaque overdraw Ideally, you’ll want to only draw each pixel once That means you will want only one fragment shader process per pixel You can check the status of that in the Metal Frame Debugger, by clicking the Counters gauge In the right side pane at the bottom there is a filter bar In there type FS Invocations followed by pressing the Enter key and then type again Pixels Stored followed by pressing the Enter key again: Overdraw would be if the number of shader invocations would be much larger than the number of pixels stored In your case, they seem to all match which means it is an opaque scene The best practice is to render opaque meshes first followed by translucent meshes If they are fully transparent that means they are invisible so they should never be rendered Submit GPU work early You can reduce latency and improve the responsiveness of your renderer by making sure all the off-screen GPU work is done early and is not waiting for the on-screen part to start raywenderlich.com 713 Metal by Tutorials Chapter 24: Performance Optimization You can that by using two or more command buffers per frame: create off-screen command buffer encode work for the GPU commit off-screen command buffer get the drawable create on-screen command buffer encode work for the GPU present the drawable commit on-screen command buffer Create the off-screen command buffer(s) and commit the work to the GPU as early as possible Get the drawable as late as possible in the frame and then have a final command buffer that only contains the on-screen work Stream resources efficiently All resources should be allocated at launch time if they are available because that will take time and will prevent render stalls later If you need to allocate resources at runtime because the renderer streams them, you should make sure you that from a dedicated thread You can see the resource allocations in the Metal System Trace, under the Allocation track: You can see here that there are a few allocations, but all at lunch time If there were allocations at runtime you would notice them later on that track and identify potential stalls because of them Design for sustained performance You should test your renderer under serious thermal state This can improve the overall thermals of the device as well as the stability and responsiveness of your renderer raywenderlich.com 714 Metal by Tutorials Chapter 24: Performance Optimization Xcode now lets you see and change the thermal state in the Devices window from Window ▸ Devices and Simulators: You can also use Xcode’s Energy Gauge to verify the thermal state that the device is running at: Memory Bandwidth best practices Since memory transfers for render targets and textures are costly, the next six best practices are targeted to memory bandwidth and how to use shared and tiled memory more efficiently raywenderlich.com 715 Metal by Tutorials Chapter 24: Performance Optimization Compress texture assets Compressing textures is very important because sampling large textures may be inefficient For that reason, you should generate mipmaps for textures that can be minified You should also compress large textures to accommodate the memory bandwidth needs There are various compression formats available For example, for older devices you could use PVRTC and for newer devices you could use ASTC Review Chapter 6, “Textures,” for how to create mipmaps and change texture formats in the asset catalog With the frame captured, you can use the Metal Memory Viewer to verify compression format, mipmap status and size You can change which columns are displayed, by right clicking the column heading: Some textures, such as render targets, cannot be compressed ahead of time so you will have to it at runtime instead The good news is, the A12 GPU and newer supports lossless texture compression which allows the GPU to compress textures for faster access Optimize for faster GPU access You should configure your textures correctly to use the appropriate storage mode depending on the use case Use the private storage mode so only the GPU has access to the texture data, allowing optimization of the contents: textureDescriptor.storageMode = private textureDescriptor.usage = [ shaderRead, renderTarget ] let texture = device.makeTexture(descriptor: textureDescriptor) raywenderlich.com 716 Metal by Tutorials Chapter 24: Performance Optimization You shouldn’t set any unnecessary usage flags such as unknown, shaderWrite or pixelView, since they may disable compression Shared textures that can be accessed by the CPU as well as the GPU, should explicitly be optimized after any CPU update on their data: textureDescriptor.storageMode = shared textureDescriptor.usage = shaderRead let texture = device.makeTexture(descriptor: textureDescriptor) // update texture data texture.replace(region: region, mipmapLevel: 0, withBytes: bytes, bytesPerRow: bytesPerRow) let blitCommandEncoder = commandBuffer.makeBlitCommandEncoder() blitCommandEncoder.optimizeContentsForGPUAccess( texture: texture) blitCommandEncoder.endEncoding() Again, the Metal Memory Viewer will show you the storage mode and usage flag for all textures, along with noticing which ones are compressed textures already, as in the previous image Choose the right pixel format Choosing the correct pixel format is crucial Not only will larger pixel formats use more bandwidth, but the sampling rate also depends on the pixel format You should try to avoid using pixel formats with unnecessary channels and also try to lower precision whenever possible You’ve generally been using the RGBA8Unorm pixel format in this book, however, when you needed greater accuracy for the G-Buffer in Chapter 14, “Multipass & Deferred Rendering,” you used a 16-bit pixel format Again, you can use the Metal Memory Viewer to see the pixel formats for textures Optimize load and store actions Load and store actions for render targets can also affect bandwidth If you have a suboptimal configuration of your pipelines caused by unnecessary load/store actions, you might create false dependencies An example of optimized configuration would be this: renderPassDescriptor.colorAttachments[0].loadAction = clear renderPassDescriptor.colorAttachments[0].storeAction = dontCare In this case, you’re configuring a color attachment to be transient which means you not want to load or store anything from it You can verify the current actions set on render targets in the Dependency Viewer raywenderlich.com 717 Metal by Tutorials Chapter 24: Performance Optimization As you can see, there is an exclamation point that suggests that you should not store the last render target 10 Optimize multi-sampled textures iOS devices have very fast multi-sampled render targets (MSAA) because they resolve from Tile Memory so it is best practice to consider MSAA over native resolution Also, make sure not to load or store the MSAA texture and set its storage mode to memoryless: textureDescriptor.textureType = type2DMultisample textureDescriptor.sampleCount = textureDescriptor.storageMode = memoryless let msaaTexture = device.makeTexture(descriptor: textureDescriptor) renderPassDesc.colorAttachments[0].texture = msaaTexture renderPassDesc.colorAttachments[0].loadAction = clear renderPassDesc.colorAttachments[0].storeAction = multisampleResolve Dependency Viewer will again help you see the current status set for load/store actions raywenderlich.com 718 Metal by Tutorials Chapter 24: Performance Optimization 11 Leverage tile memory Metal provides access to Tile Memory for several features such as programmable blending, image blocks and tile shaders Deferred shading requires storing the GBuffer in a first pass, and then sampling from its textures in the second lighting pass where the final color accumulates into a render target This is very bandwidth-heavy iOS allows fragment shaders to access pixel data directly from Tile Memory in order to leverage programmable blending This means that you can store the G-Buffer data on Tile Memory and all the light accumulation shaders can access it within the same render pass The four G-Buffer attachments are fully transient and only the final color and depth are stored, so it’s very efficient Memory Footprint best practices 12 Use memoryless render targets As mentioned previously in best practices and 10, you should be using memoryless storage mode for all transient render targets which not need a memory allocation, that is, are not loaded from or stored to memory: textureDescriptor.storageMode = memoryless textureDescriptor.usage = [ shaderRead, renderTarget ] // for each G-Buffer texture textureDescriptor.pixelFormat = gBufferPixelFormats[i] gBufferTextures[i] = device.makeTexture(descriptor: textureDescriptor) renderPassDescriptor.colorAttachments[i].texture = gBufferTextures[i] renderPassDescriptor.colorAttachments[i].loadAction = clear renderPassDescriptor.colorAttachments[i].storeAction = dontCare You’ll be able to see the change immediately in the Dependency Viewer 13 Avoid loading unused assets Loading all the assets into memory will increase the memory footprint so you should consider the memory and performance trade-off and only load all the assets that you know will be used The GPU frame capture Memory Viewer will show you any unused resources: raywenderlich.com 719 Metal by Tutorials Chapter 24: Performance Optimization Fortunately, your app correctly uses all its textures 14 Use smaller assets You should only make the assets as large as necessary and consider again the image quality and memory trade-off of your asset sizes Make sure that both textures and meshes are compressed You may want to only load the smaller mipmap levels of your textures, or use lower level of detail meshes for distant objects 15 Simplify memory-intensive effects Some effects may require large off-screen buffers, such as Shadow Maps and Screen Space Ambient Occlusion so you should consider the image quality and memory trade-off of all of those effects, potentially lower the resolution of all these large offscreen buffers and even disable the memory-intensive effects altogether when you are memory constrained 16 Use Metal resource heaps Rendering a frame may require a lot of intermediate memory especially if your game becomes more complex in the post-process pipeline so it is very important to use Metal Resource Heaps for those effects and alias as much of that memory as possible For example, you may want to reutilize the memory for resources which have no dependencies such as those for Depth of Field or Screen Space Ambient Occlusion Another advanced concept is that of purgeable memory Purgeable memory has three states: non-volatile (when data should not be discarded), volatile (data can be discarded even when the resource may be needed) and empty (data has been discarded) Volatile and empty allocations not count towards the application memory footprint because the system can either reclaim that memory at some point or has already reclaimed it in the past 17 Mark resources as volatile Temporary resources may become a large part of the memory footprint and Metal will allow you to set the purgeable state of all the resources explicitly You will want to focus on your caches that hold mostly idle memory and carefully manage their purgeable state, like in this example: // for each texture in the cache texturePool[i].setPurgeableState(.volatile) // later on if (texturePool[i].setPurgeableState(.nonVolatile) == empty) { // regenerate texture } raywenderlich.com 720 Metal by Tutorials Chapter 24: Performance Optimization 18 Manage the Metal PSOs Pipeline State Objects (PSOs) encapsulate most of the Metal render state You create them using a descriptor which contains vertex and fragment functions as well as other state descriptors All of these will get compiled into the final Metal PSO Metal allows your application to load most of the rendering state up front, improving the performance over OpenGL However, if you have limited memory make sure to not hold on to PSO references that you don’t need anymore Also don’t hold on to Metal function references after you have created the PSO cache because they are not needed to render, they are only needed to create new PSOs Note: Apple have written a Metal Best Practices guide that provides great advice for optimizing your app: https://developer.apple.com/library/archive/ documentation/3DDrawing/Conceptual/MTLBestPracticesGuide/index.html Where to go from here? Getting the last ounce of performance out of your app is paramount You’ve had a taste of examining CPU and GPU performance using Instruments, but to go further, you’ll need Apple’s Instruments documentation at https://help.apple.com/ instruments/mac/10.0/ Over the years, at every WWDC since Metal was introduced, Apple have produced some excellent WWDC videos describing Metal best practices and optimization techniques Go to https://developer.apple.com/videos/graphics-and-games/metal/ and watch as many as you can, as often as you can Congratulations on completing the book! The world of Computer Graphics is vast and as complex as you want to make it But now that you have the basics of Metal learned, even though current internet resources are few, you should be able to learn techniques described with other APIs such as OpenGL, Vulkan and DirectX If you’re keen to learn more, look at the books suggested in references.markdown raywenderlich.com 721 C Conclusion Thank you again for purchasing Metal by Tutorials If you have any questions or comments as you continue to develop for Metal, please stop by our forums at http:// forums.raywenderlich.com Your continued support is what makes the tutorials, books, videos and other things we at raywenderlich.com possible We truly appreciate it Best of luck in all your development and game-making adventures, – Caroline Begbie (Author), Marius Horga (Author), Adrian Strahan (Tech Editor) and Tammy Coron (FPE) The Metal by Tutorials team raywenderlich.com 722 .. .Metal by Tutorials Metal by Tutorials By Caroline Begbie & Marius Horga Copyright ©2019 Razeware LLC Notice of Rights All rights... project later on raywenderlich. com 17 L Book License By purchasing Metal by Tutorials, you have the following license: • You are allowed to use and/or modify the source code in Metal by Tutorials in... anywhere, at anytime raywenderlich. com 19 Metal by Tutorials Book Source Code & Forums Visit our book store page here: • https://store .raywenderlich. com/products /metal- by- tutorials And if you

Ngày đăng: 17/05/2021, 07:53

TỪ KHÓA LIÊN QUAN

TÀI LIỆU CÙNG NGƯỜI DÙNG

TÀI LIỆU LIÊN QUAN